diff --git a/src/llm/provider/openai/openai-provider.test.ts b/src/llm/provider/openai/openai-provider.test.ts index b4b6afda..783f87ad 100644 --- a/src/llm/provider/openai/openai-provider.test.ts +++ b/src/llm/provider/openai/openai-provider.test.ts @@ -262,3 +262,54 @@ describe("OpenAiProvider strictTools wiring", () => { }); }); }); + +describe("OpenAiProvider without defaultChatModel", () => { + it("throws on complete() when defaultChatModel is not configured", async () => { + const p = new OpenAiProvider({ + id: "test", + baseUrl: "https://example.invalid", + apiKey: "test-key", + fetchImpl: fakeFetch({ + role: "assistant", + content: "ok", + }) as unknown as typeof fetch, + }); + await expect(p.complete({ prompt: "hi" })).rejects.toThrow( + /has no defaultChatModel configured/, + ); + }); + + it("throws on completeStream() when defaultChatModel is not configured", async () => { + const p = new OpenAiProvider({ + id: "test", + baseUrl: "https://example.invalid", + apiKey: "test-key", + fetchImpl: fakeStreamFetch("ok") as unknown as typeof fetch, + }); + const stream = p.completeStream({ prompt: "hi" }); + await expect(stream.next()).rejects.toThrow( + /has no defaultChatModel configured/, + ); + }); + + it("throws on describeImage() when defaultChatModel is not configured", async () => { + const p = new OpenAiProvider({ + id: "test", + baseUrl: "https://example.invalid", + apiKey: "test-key", + supportsVision: true, + fetchImpl: fakeFetch({ + role: "assistant", + content: "ok", + }) as unknown as typeof fetch, + }); + await expect( + p.describeImage({ + prompt: "describe", + images: [ + { id: 0, bytes: new Uint8Array(), mimeType: "image/png" }, + ], + }), + ).rejects.toThrow(/has no defaultChatModel configured/); + }); +}); diff --git a/src/llm/provider/openai/openai-provider.ts b/src/llm/provider/openai/openai-provider.ts index 5712b3e3..9f3234a8 100644 --- a/src/llm/provider/openai/openai-provider.ts +++ b/src/llm/provider/openai/openai-provider.ts @@ -48,7 +48,7 @@ export interface OpenAiProviderOptions { id: string; baseUrl: string; apiKey: string; - defaultChatModel: string; + defaultChatModel?: string; headers?: Record; /** * Header that carries the API key when the service does not accept @@ -104,7 +104,7 @@ export class OpenAiProvider implements LlmProvider { readonly capabilities: ProviderCapabilities; private readonly http: OpenAiHttpDeps; - private readonly defaultChatModel: string; + private readonly defaultChatModel: string | undefined; private readonly apiPathPrefix: string; private readonly taggedToolCompatibility: "qwen" | undefined; private readonly extraBody: Record | undefined; @@ -157,18 +157,24 @@ export class OpenAiProvider implements LlmProvider { } async complete(request: CompletionRequest): Promise { + const model = this.defaultChatModel; + if (!model) { + throw new Error( + `openai-compatible provider "${this.name}" has no defaultChatModel configured`, + ); + } // Unary only: sub-calls carry `response_format`, streamed turns never do. const json = await sendWithStructuredOutputFallback( { providerId: this.id, - model: this.defaultChatModel, + model, logger: this.http.logger, }, request, (req) => buildOpenAiChatBody( req, - this.defaultChatModel, + model, false, this.extraBody, this.maxOutputTokens, @@ -187,15 +193,21 @@ export class OpenAiProvider implements LlmProvider { this.taggedToolCompatibility === "qwen" ? adaptQwenTaggedToolResponse(json, request) : json; - return normaliseOpenAiChatResponse(adapted, this.defaultChatModel); + return normaliseOpenAiChatResponse(adapted, model); } async *completeStream( request: CompletionRequest, ): AsyncGenerator { + const model = this.defaultChatModel; + if (!model) { + throw new Error( + `openai-compatible provider "${this.name}" has no defaultChatModel configured`, + ); + } const body = buildOpenAiChatBody( request, - this.defaultChatModel, + model, true, this.extraBody, this.maxOutputTokens, @@ -334,7 +346,7 @@ export class OpenAiProvider implements LlmProvider { } const final = completionFromStreamFinal( streamFinal, - this.defaultChatModel, + model, accumulated, accumulatedReasoning, ); @@ -396,9 +408,15 @@ export class OpenAiProvider implements LlmProvider { if (!this.capabilities.vision) { throw new VisionUnsupportedError(this.name); } + const model = this.defaultChatModel; + if (!model) { + throw new Error( + `openai-compatible provider "${this.name}" has no defaultChatModel configured`, + ); + } return describeImageViaOpenAi( this.http, - this.defaultChatModel, + model, request, this.apiPathPrefix, this.providerPreferences, @@ -421,7 +439,7 @@ function normalizeApiPathPrefix(prefix: string): string { function completionFromStreamFinal( streamFinal: StreamFinalResult | void, - defaultChatModel: string, + defaultChatModel: string | undefined, accumulated: string, accumulatedReasoning: string, ): CompletionResult { @@ -444,7 +462,7 @@ function completionFromStreamFinal( }, cacheHitTokens: 0, slotId: -1, - modelId: streamFinal?.modelId ?? defaultChatModel, + modelId: streamFinal?.modelId ?? defaultChatModel ?? null, usage, toolCalls: streamFinal?.toolCalls, finishReason, diff --git a/src/llm/provider/registry/provider-registry.test.ts b/src/llm/provider/registry/provider-registry.test.ts index e7a67281..2e309421 100644 --- a/src/llm/provider/registry/provider-registry.test.ts +++ b/src/llm/provider/registry/provider-registry.test.ts @@ -87,4 +87,78 @@ describe("ProviderRegistry", () => { }), ).rejects.toThrow(/unknown llm provider kind/); }); + + it("allows embedding-only openai-compatible provider without defaultChatModel", async () => { + registerBuiltInProviderKinds(); + const fakeConfig = { + ...getConfig(), + llm: { + activeTextProvider: "chat-provider", + activeEmbeddingProvider: "embed-only", + toolTransport: "auto" as const, + providers: [ + { + id: "chat-provider", + kind: "openai-compatible", + baseUrl: "https://example.invalid", + defaultChatModel: "gpt-4", + }, + { + id: "embed-only", + kind: "openai-compatible", + baseUrl: "https://example.invalid", + defaultEmbeddingModel: "nomic-embed-text", + userModels: [ + { + id: "nomic-embed-text", + kind: "embedding" as const, + dim: 768, + }, + ], + }, + ], + }, + } as AtomicAgentConfig; + const registry = await ProviderRegistry.fromConfig(fakeConfig, { + config: fakeConfig, + llamaClient: {} as never, + getProfile: () => ({}) as never, + logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }); + expect(registry.listIds()).toContain("embed-only"); + }); + + it("rejects non-embedding openai-compatible provider without defaultChatModel", async () => { + registerBuiltInProviderKinds(); + const fakeConfig = { + ...getConfig(), + llm: { + activeTextProvider: "chat-provider", + activeEmbeddingProvider: "embed-only", + toolTransport: "auto" as const, + providers: [ + { + id: "chat-provider", + kind: "openai-compatible", + baseUrl: "https://example.invalid", + defaultChatModel: "gpt-4", + }, + { + id: "bad-provider", + kind: "openai-compatible", + baseUrl: "https://example.invalid", + userModels: [{ id: "gpt-4", kind: "chat" as const }], + }, + ], + }, + } as AtomicAgentConfig; + await expect( + ProviderRegistry.fromConfig(fakeConfig, { + config: fakeConfig, + llamaClient: {} as never, + getProfile: () => ({}) as never, + logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }), + ).rejects.toThrow(/requires defaultChatModel/); + }); }); diff --git a/src/llm/provider/registry/register-built-in-providers.ts b/src/llm/provider/registry/register-built-in-providers.ts index 1ef3d125..23d8654e 100644 --- a/src/llm/provider/registry/register-built-in-providers.ts +++ b/src/llm/provider/registry/register-built-in-providers.ts @@ -49,9 +49,18 @@ export function registerBuiltInProviderKinds(): void { registerProviderKind("openai-compatible", (ctx) => { const entry = ctx.entry; - if (!entry.baseUrl || !entry.defaultChatModel) { + const isEmbeddingOnly = + entry.userModels !== undefined && + entry.userModels.length > 0 && + entry.userModels.every((m) => m.kind === "embedding"); + if (!entry.baseUrl) { throw new Error( - `openai-compatible provider "${entry.id}" requires baseUrl and defaultChatModel`, + `openai-compatible provider "${entry.id}" requires baseUrl`, + ); + } + if (!isEmbeddingOnly && !entry.defaultChatModel) { + throw new Error( + `openai-compatible provider "${entry.id}" requires defaultChatModel`, ); } return new OpenAiProvider({ @@ -73,9 +82,18 @@ export function registerBuiltInProviderKinds(): void { registerProviderKind("qwen-openai-compatible", (ctx) => { const entry = ctx.entry; - if (!entry.baseUrl || !entry.defaultChatModel) { + const isEmbeddingOnly = + entry.userModels !== undefined && + entry.userModels.length > 0 && + entry.userModels.every((m) => m.kind === "embedding"); + if (!entry.baseUrl) { + throw new Error( + `qwen-openai-compatible provider "${entry.id}" requires baseUrl`, + ); + } + if (!isEmbeddingOnly && !entry.defaultChatModel) { throw new Error( - `qwen-openai-compatible provider "${entry.id}" requires baseUrl and defaultChatModel`, + `qwen-openai-compatible provider "${entry.id}" requires defaultChatModel`, ); } return new OpenAiProvider({