Skip to content

Commit d41bd17

Browse files
vraj00222claude
andcommitted
feat(sdk): add custom OpenAI-compatible provider branch to getModelForRequest
When ModelRequestParams.customProvider.baseUrl is set, return an OpenAICompatibleChatLanguageModel pointed at that endpoint and flag the result with isCustomProvider: true. Bypasses both the Codebuff backend and the ChatGPT OAuth direct path. No metadataExtractor — direct calls don't flow through Codebuff cost accounting. Mirrors the existing ChatGPT-OAuth-direct branch pattern. Trailing slashes on baseUrl are trimmed. apiKey defaults to "codebuff" when absent (most local runtimes ignore it). Adds 5 unit tests covering the new branch, regression-tested against existing model-provider-free-mode tests. Part of issue #678. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6752d83 commit d41bd17

2 files changed

Lines changed: 120 additions & 1 deletion

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, test, afterEach, mock } from 'bun:test'
2+
3+
describe('getModelForRequest with customProvider', () => {
4+
afterEach(() => {
5+
mock.restore()
6+
})
7+
8+
test('returns isCustomProvider: true when customProvider.baseUrl is set', async () => {
9+
const { getModelForRequest } = await import('../model-provider')
10+
11+
const result = await getModelForRequest({
12+
apiKey: 'cb-test-key',
13+
model: 'gemma2:9b',
14+
customProvider: { baseUrl: 'http://localhost:11434/v1', apiKey: 'ollama' },
15+
})
16+
17+
expect(result.isCustomProvider).toBe(true)
18+
expect(result.isChatGptOAuth).toBe(false)
19+
expect(result.model).toBeDefined()
20+
expect((result.model as any).modelId).toBe('gemma2:9b')
21+
})
22+
23+
test('does not return isCustomProvider when baseUrl is missing', async () => {
24+
const { getModelForRequest } = await import('../model-provider')
25+
26+
const result = await getModelForRequest({
27+
apiKey: 'cb-test-key',
28+
model: 'anthropic/claude-sonnet-4',
29+
})
30+
31+
expect(result.isCustomProvider).toBe(false)
32+
})
33+
34+
test('customProvider takes precedence over ChatGPT OAuth eligibility', async () => {
35+
const { getModelForRequest } = await import('../model-provider')
36+
37+
const result = await getModelForRequest({
38+
apiKey: 'cb-test-key',
39+
model: 'openai/gpt-5.3',
40+
customProvider: { baseUrl: 'http://localhost:11434/v1' },
41+
})
42+
43+
expect(result.isCustomProvider).toBe(true)
44+
expect(result.isChatGptOAuth).toBe(false)
45+
})
46+
47+
test('trims trailing slash from baseUrl (constructs cleanly)', async () => {
48+
const { getModelForRequest } = await import('../model-provider')
49+
50+
const result = await getModelForRequest({
51+
apiKey: 'cb-test-key',
52+
model: 'gemma2:9b',
53+
customProvider: { baseUrl: 'http://localhost:11434/v1/' },
54+
})
55+
56+
expect(result.isCustomProvider).toBe(true)
57+
})
58+
59+
test('omitting apiKey is allowed', async () => {
60+
const { getModelForRequest } = await import('../model-provider')
61+
62+
const result = await getModelForRequest({
63+
apiKey: 'cb-test-key',
64+
model: 'gemma2:9b',
65+
customProvider: { baseUrl: 'http://localhost:11434/v1' },
66+
})
67+
68+
expect(result.isCustomProvider).toBe(true)
69+
})
70+
})

sdk/src/impl/model-provider.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export interface ModelRequestParams {
8686
skipChatGptOAuth?: boolean
8787
/** Cost mode (e.g. 'free') — affects fallback behavior for OAuth routes */
8888
costMode?: string
89+
/** When set, route this request directly to the OpenAI-compatible endpoint and bypass Codebuff/OAuth. */
90+
customProvider?: { baseUrl: string; apiKey?: string }
8991
}
9092

9193
/**
@@ -96,6 +98,8 @@ export interface ModelResult {
9698
model: LanguageModel
9799
/** Whether this model uses ChatGPT OAuth direct (affects cost tracking) */
98100
isChatGptOAuth: boolean
101+
/** Whether this model uses a custom OpenAI-compatible endpoint (affects cost tracking + metadata) */
102+
isCustomProvider: boolean
99103
}
100104

101105
// Usage accounting type for OpenRouter/Codebuff backend responses
@@ -115,7 +119,21 @@ type OpenRouterUsageAccounting = {
115119
* This function is async because it may need to refresh the OAuth token.
116120
*/
117121
export async function getModelForRequest(params: ModelRequestParams): Promise<ModelResult> {
118-
const { apiKey, model, skipChatGptOAuth, costMode } = params
122+
const { apiKey, model, skipChatGptOAuth, costMode, customProvider } = params
123+
124+
// 1) Custom OpenAI-compatible endpoint wins — explicit per-agent / client / env override.
125+
// Bypasses Codebuff backend AND ChatGPT OAuth.
126+
if (customProvider?.baseUrl) {
127+
return {
128+
model: createCustomProviderModel({
129+
model,
130+
baseUrl: customProvider.baseUrl,
131+
apiKey: customProvider.apiKey,
132+
}),
133+
isChatGptOAuth: false,
134+
isCustomProvider: true,
135+
}
136+
}
119137

120138
// Check if we should use ChatGPT OAuth direct
121139
// Only attempt for allowlisted models; non-allowlisted models silently fall through to backend.
@@ -140,6 +158,7 @@ export async function getModelForRequest(params: ModelRequestParams): Promise<Mo
140158
return {
141159
model: createOpenAIOAuthModel(model, chatGptOAuthCredentials.accessToken),
142160
isChatGptOAuth: true,
161+
isCustomProvider: false,
143162
}
144163
}
145164

@@ -156,6 +175,7 @@ export async function getModelForRequest(params: ModelRequestParams): Promise<Mo
156175
return {
157176
model: createCodebuffBackendModel(apiKey, model),
158177
isChatGptOAuth: false,
178+
isCustomProvider: false,
159179
}
160180
}
161181

@@ -256,3 +276,32 @@ function createCodebuffBackendModel(
256276
supportsStructuredOutputs: true,
257277
})
258278
}
279+
280+
/**
281+
* Create an OpenAI-compatible model pointed at a user-supplied base URL.
282+
* Used for local providers (Ollama, LM Studio) and self-hosted endpoints.
283+
*
284+
* No metadata extractor — direct calls don't flow through Codebuff's usage
285+
* accounting. No codebuff_metadata is sent (handled by the caller).
286+
*/
287+
function createCustomProviderModel(params: {
288+
model: string
289+
baseUrl: string
290+
apiKey?: string
291+
}): LanguageModel {
292+
const { model, baseUrl, apiKey } = params
293+
const trimmedBase = baseUrl.replace(/\/+$/, '')
294+
295+
return new OpenAICompatibleChatLanguageModel(model, {
296+
provider: 'custom',
297+
url: ({ path: endpoint }) => `${trimmedBase}${endpoint}`,
298+
headers: () => ({
299+
Authorization: `Bearer ${apiKey ?? 'codebuff'}`,
300+
'Content-Type': 'application/json',
301+
'user-agent': `ai-sdk/openai-compatible/${VERSION}/codebuff-custom-provider`,
302+
}),
303+
fetch: undefined,
304+
includeUsage: undefined,
305+
supportsStructuredOutputs: true,
306+
})
307+
}

0 commit comments

Comments
 (0)