From 62ece99dc06c62c10bb4e03977f5b5f1aae9852d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 14:07:57 +0200 Subject: [PATCH] fix(provider): route Azure GPT models through chat --- docs/api-reference/veryfront/provider.md | 26 +++++----- .../src/openai-provider.test.ts | 44 +++++++++++++++++ .../ext-llm-openai/src/openai-provider.ts | 47 +++++++++++++++++- .../veryfront-cloud/model-catalog.test.ts | 18 +++++++ src/provider/veryfront-cloud/model-catalog.ts | 10 ++++ src/provider/veryfront-cloud/openai.ts | 2 + src/provider/veryfront-cloud/provider.test.ts | 48 +++++++++++++++++++ src/provider/veryfront-cloud/provider.ts | 12 ++++- 8 files changed, 191 insertions(+), 16 deletions(-) diff --git a/docs/api-reference/veryfront/provider.md b/docs/api-reference/veryfront/provider.md index dfb3f438df..a815bacc12 100644 --- a/docs/api-reference/veryfront/provider.md +++ b/docs/api-reference/veryfront/provider.md @@ -66,7 +66,7 @@ Clear all registered model providers and reset lazy built-ins (for testing). | Name | Description | Source | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `DEFAULT_VERYFRONT_CLOUD_MODEL_ID` | Default Veryfront Cloud model ID used when no model is configured. Update this when the current default is deprecated - otherwise the default path silently breaks for users who have not set an explicit model. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L48) | -| `VERYFRONT_CLOUD_CHAT_MODELS` | Shared Veryfront Cloud chat models value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L234) | +| `VERYFRONT_CLOUD_CHAT_MODELS` | Shared Veryfront Cloud chat models value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L244) | | `VERYFRONT_CLOUD_MODEL_PREFIX` | Shared Veryfront Cloud model prefix value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L50) | ### Functions @@ -75,26 +75,26 @@ Clear all registered model providers and reset lazy built-ins (for testing). | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `clearModelProviders` | Clear all registered model providers and reset lazy built-ins (for testing). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/model-registry.ts#L402) | | `ensureModelReady` | Eagerly verify that the resolved model's runtime is available. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/model-registry.ts#L389) | -| `findVeryfrontCloudModel` | Find Veryfront Cloud model. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L266) | -| `findVeryfrontCloudModelByModelId` | Find Veryfront Cloud model by model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L278) | +| `findVeryfrontCloudModel` | Find Veryfront Cloud model. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L276) | +| `findVeryfrontCloudModelByModelId` | Find Veryfront Cloud model by model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L288) | | `getCurrentVeryfrontCloudContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/context.ts#L31) | | `getRegisteredModelProviders` | Get provider names available in the current scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/model-registry.ts#L377) | | `getVeryfrontCloudBootstrap` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/cloud/resolver.ts#L121) | -| `getVeryfrontCloudProviderFromModelId` | Return Veryfront Cloud provider from model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L286) | -| `groupVeryfrontCloudModelsByProvider` | Group Veryfront Cloud models by provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L477) | +| `getVeryfrontCloudProviderFromModelId` | Return Veryfront Cloud provider from model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L296) | +| `groupVeryfrontCloudModelsByProvider` | Group Veryfront Cloud models by provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L487) | | `hasModelProvider` | Check whether a model provider is available in the current scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/model-registry.ts#L363) | | `markCurrentVeryfrontCloudBillingGroupUsed` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/context.ts#L35) | -| `normalizeVeryfrontCloudModelId` | Normalizes Veryfront Cloud model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L271) | +| `normalizeVeryfrontCloudModelId` | Normalizes Veryfront Cloud model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L281) | | `registerModelProvider` | Register a custom model provider factory for the active project scope or application bootstrap. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/model-registry.ts#L119) | | `resolveModel` | Resolve a "provider/model" string to a framework-compatible model runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/model-registry.ts#L304) | -| `resolveVeryfrontCloudGatewayModelId` | Resolves Veryfront Cloud gateway model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L337) | -| `resolveVeryfrontCloudModelId` | Resolves Veryfront Cloud model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L311) | -| `resolveVeryfrontCloudModelThinking` | Resolves Veryfront Cloud model thinking. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L363) | -| `resolveVeryfrontCloudReasoningOption` | Resolves provider-neutral runtime reasoning for a Veryfront Cloud model. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L383) | -| `resolveVeryfrontCloudThinkingProviderOptions` | Options accepted by resolve Veryfront Cloud thinking provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L417) | +| `resolveVeryfrontCloudGatewayModelId` | Resolves Veryfront Cloud gateway model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L347) | +| `resolveVeryfrontCloudModelId` | Resolves Veryfront Cloud model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L321) | +| `resolveVeryfrontCloudModelThinking` | Resolves Veryfront Cloud model thinking. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L373) | +| `resolveVeryfrontCloudReasoningOption` | Resolves provider-neutral runtime reasoning for a Veryfront Cloud model. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L393) | +| `resolveVeryfrontCloudThinkingProviderOptions` | Options accepted by resolve Veryfront Cloud thinking provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L427) | | `runWithVeryfrontCloudContext` | Context for run with Veryfront Cloud. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/context.ts#L16) | | `runWithVeryfrontCloudContextAsync` | Run with Veryfront Cloud context async. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/context.ts#L24) | -| `tryGetVeryfrontCloudProviderFromModelId` | Try to get Veryfront Cloud provider from model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L300) | +| `tryGetVeryfrontCloudProviderFromModelId` | Try to get Veryfront Cloud provider from model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L310) | ### Types @@ -113,7 +113,7 @@ Clear all registered model providers and reset lazy built-ins (for testing). | Name | Description | Source | | ------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `resolveHostedVeryfrontCloudModelId` | Resolves hosted Veryfront Cloud model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L492) | +| `resolveHostedVeryfrontCloudModelId` | Resolves hosted Veryfront Cloud model ID. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/veryfront-cloud/model-catalog.ts#L502) | ## Deep imports diff --git a/extensions/ext-llm-openai/src/openai-provider.test.ts b/extensions/ext-llm-openai/src/openai-provider.test.ts index f59a691876..405dab6132 100644 --- a/extensions/ext-llm-openai/src/openai-provider.test.ts +++ b/extensions/ext-llm-openai/src/openai-provider.test.ts @@ -1008,6 +1008,50 @@ describe("openai-provider", () => { assertEquals(chatResult.content, [{ type: "text", text: "Chat response" }]); }); + it("rejects hosted search when Chat Completions is explicitly configured", async () => { + let fetchCalled = false; + const provider = new OpenAIProvider(); + const runtime = provider.createModel("gpt-5.4", { + credential: "test-openai-key", + baseURL: "https://example.openai.test/v1", + openAITransport: "chat-completions", + fetch: () => { + fetchCalled = true; + return Promise.resolve(new Response("{}", { status: 200 })); + }, + }); + + await assertRejects( + () => + runtime.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Research Veryfront." }] }], + tools: [{ + type: "provider", + name: "web_search", + id: "openai.web_search", + args: {}, + }], + }), + TypeError, + "OpenAI hosted tools require the Responses API", + ); + await assertRejects( + () => + runtime.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Research Veryfront." }] }], + tools: [{ + type: "provider", + name: "web_search", + id: "openai.web_search", + args: {}, + }], + }), + TypeError, + "OpenAI hosted tools require the Responses API", + ); + assertEquals(fetchCalled, false); + }); + it("keeps OpenAI-compatible provider identity separate from display labels", async () => { const encoder = new TextEncoder(); let requestedUrl = ""; diff --git a/extensions/ext-llm-openai/src/openai-provider.ts b/extensions/ext-llm-openai/src/openai-provider.ts index 811eb27517..0de03a6a39 100644 --- a/extensions/ext-llm-openai/src/openai-provider.ts +++ b/extensions/ext-llm-openai/src/openai-provider.ts @@ -115,6 +115,17 @@ function getLLMOpenAIProviderName(config: LLMProviderConfig): string { return normalizeOpenAIProviderName(config.providerName) ?? "openai"; } +type OpenAIModelTransport = "auto" | "chat-completions" | "responses"; + +function getOpenAIModelTransport(config: LLMProviderConfig): OpenAIModelTransport { + const transport = config.openAITransport; + if (transport === undefined) return "auto"; + if (transport === "chat-completions" || transport === "responses") { + return transport; + } + throw new TypeError('OpenAI transport must be "chat-completions" or "responses"'); +} + type OpenAICompatibleProviderKind = "openai" | "mistral" | "moonshotai"; type OpenAIResponseContext = { @@ -1218,6 +1229,30 @@ function requestUsesOpenAIHostedTool(optionsForRuntime: OpenAICompatibleLanguage ) !== undefined; } +function createOpenAIChatCompletionsOnlyRuntime( + chatRuntime: ModelRuntime, +): ModelRuntime { + function assertHostedToolsSupported(options: OpenAICompatibleLanguageOptions): void { + if (requestUsesOpenAIHostedTool(options)) { + throw new TypeError( + "OpenAI hosted tools require the Responses API and are unavailable with Chat Completions", + ); + } + } + + return { + ...chatRuntime, + async doGenerate(optionsForRuntime: OpenAICompatibleLanguageOptions) { + assertHostedToolsSupported(optionsForRuntime); + return await chatRuntime.doGenerate(optionsForRuntime); + }, + async doStream(optionsForRuntime: OpenAICompatibleLanguageOptions) { + assertHostedToolsSupported(optionsForRuntime); + return await chatRuntime.doStream(optionsForRuntime); + }, + }; +} + function createOpenAIAdaptiveModelRuntime( chatRuntime: ModelRuntime, responsesRuntime: ModelRuntime, @@ -1305,12 +1340,20 @@ export class OpenAIProvider implements LLMProvider { runtimeConfig, modelId, ); - if (isOpenAIReasoningModel(modelId, providerName)) { + const chatRuntime = createOpenAIModelRuntime(runtimeConfig, modelId); + const transport = getOpenAIModelTransport(config); + if ( + transport === "responses" || + (transport === "auto" && isOpenAIReasoningModel(modelId, providerName)) + ) { return responsesRuntime; } + if (transport === "chat-completions") { + return createOpenAIChatCompletionsOnlyRuntime(chatRuntime); + } return createOpenAIAdaptiveModelRuntime( - createOpenAIModelRuntime(runtimeConfig, modelId), + chatRuntime, responsesRuntime, ); } diff --git a/src/provider/veryfront-cloud/model-catalog.test.ts b/src/provider/veryfront-cloud/model-catalog.test.ts index 88972289a1..98d43084a3 100644 --- a/src/provider/veryfront-cloud/model-catalog.test.ts +++ b/src/provider/veryfront-cloud/model-catalog.test.ts @@ -14,6 +14,7 @@ import { resolveVeryfrontCloudGatewayModelId, resolveVeryfrontCloudModelId, resolveVeryfrontCloudModelThinking, + resolveVeryfrontCloudOpenAITransport, resolveVeryfrontCloudReasoningOption, resolveVeryfrontCloudThinkingProviderOptions, tryGetVeryfrontCloudProviderFromModelId, @@ -198,6 +199,23 @@ describe("provider/veryfront-cloud/model-catalog", () => { } }); + it("resolves model-specific OpenAI transport overrides", () => { + for ( + const modelId of [ + "openai/gpt-5.4", + "veryfront-cloud/openai/gpt-5.4", + "openai/gpt-5.5", + "veryfront-cloud/openai/gpt-5.5", + ] + ) { + assertEquals(resolveVeryfrontCloudOpenAITransport(modelId), "chat-completions"); + } + + assertEquals(resolveVeryfrontCloudOpenAITransport("openai/gpt-5.2"), undefined); + assertEquals(resolveVeryfrontCloudOpenAITransport("openai/gpt-5.4-mini"), undefined); + assertEquals(resolveVeryfrontCloudOpenAITransport("openai/gpt-5.4-nano"), undefined); + }); + it("rejects non-positive and non-safe thinking budgets", () => { const invalidBudgets = [ 0, diff --git a/src/provider/veryfront-cloud/model-catalog.ts b/src/provider/veryfront-cloud/model-catalog.ts index d74221569f..8349524957 100644 --- a/src/provider/veryfront-cloud/model-catalog.ts +++ b/src/provider/veryfront-cloud/model-catalog.ts @@ -71,6 +71,7 @@ export function normalizeVeryfrontCloudProviderAlias( type VeryfrontCloudModelTransportCapabilities = { readonly anthropicThinkingMode?: "adaptive"; + readonly openAITransport?: "chat-completions" | "responses"; }; /** @@ -85,6 +86,8 @@ const VERYFRONT_CLOUD_MODEL_TRANSPORT_CAPABILITIES = new Map< >([ ["anthropic/claude-opus-4-7", Object.freeze({ anthropicThinkingMode: "adaptive" })], ["anthropic/claude-opus-4-8", Object.freeze({ anthropicThinkingMode: "adaptive" })], + ["openai/gpt-5.4", Object.freeze({ openAITransport: "chat-completions" })], + ["openai/gpt-5.5", Object.freeze({ openAITransport: "chat-completions" })], ]); function getVeryfrontCloudModelTransportCapabilities( @@ -95,6 +98,13 @@ function getVeryfrontCloudModelTransportCapabilities( ); } +/** Resolves a model-specific OpenAI transport override for Veryfront Cloud. */ +export function resolveVeryfrontCloudOpenAITransport( + modelId: string, +): "chat-completions" | "responses" | undefined { + return getVeryfrontCloudModelTransportCapabilities(modelId)?.openAITransport; +} + /** Returns true if the given model ID is a Mistral model in the catalog. */ export function isSupportedMistralModelId(modelId: string): boolean { return VERYFRONT_CLOUD_CHAT_MODELS.some( diff --git a/src/provider/veryfront-cloud/openai.ts b/src/provider/veryfront-cloud/openai.ts index 4ca968aac5..e856d920cb 100644 --- a/src/provider/veryfront-cloud/openai.ts +++ b/src/provider/veryfront-cloud/openai.ts @@ -6,6 +6,7 @@ const openLLMProvider = new OpenAIProvider(); interface VeryfrontCloudOpenAIConfig { apiToken: string; baseURL: string; + openAITransport?: "chat-completions" | "responses"; fetch: typeof globalThis.fetch; } @@ -18,6 +19,7 @@ export function createVeryfrontCloudOpenAIModel( baseURL: config.baseURL, name: "veryfront-cloud", providerName: "veryfront-cloud", + openAITransport: config.openAITransport, fetch: config.fetch, }); } diff --git a/src/provider/veryfront-cloud/provider.test.ts b/src/provider/veryfront-cloud/provider.test.ts index 41d24f4bbc..87caccf36b 100644 --- a/src/provider/veryfront-cloud/provider.test.ts +++ b/src/provider/veryfront-cloud/provider.test.ts @@ -197,6 +197,54 @@ describe("provider/veryfront-cloud", () => { }); }); + it("routes Azure-backed GPT models through Chat Completions", async () => { + setCloudBootstrap(); + const encoder = new TextEncoder(); + const capturedRequests: Array<{ url: string; body: Record }> = []; + + globalThis.fetch = (async (input: URL | Request | string, init?: RequestInit) => { + const request = new Request(input, init); + capturedRequests.push({ + url: request.url, + body: JSON.parse(await request.text()) as Record, + }); + + return new Response( + readableStreamFrom([ + encoder.encode( + 'data: {"prompt_filter_results":[{"prompt_index":0,"content_filter_results":{}}],"choices":[]}\n\n', + ), + encoder.encode('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'), + encoder.encode('data: {"choices":[{"finish_reason":"stop"}]}\n\n'), + encoder.encode("data: [DONE]\n\n"), + ]), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch; + + for (const modelId of ["gpt-5.4", "gpt-5.5"]) { + const assistant = agent({ + model: `veryfront-cloud/openai/${modelId}`, + system: "You are concise.", + }); + + const result = await assistant.generate({ input: "Hi" }); + assertEquals(result.text, "Hello"); + } + + assertEquals( + capturedRequests.map(({ url }) => url), + [ + "https://api.veryfront.com/ai/gateway/openai/v1/chat/completions", + "https://api.veryfront.com/ai/gateway/openai/v1/chat/completions", + ], + ); + assertEquals( + capturedRequests.map(({ body }) => body.reasoning_effort), + ["medium", "medium"], + ); + }); + it("routes reasoning-capable OpenAI models through Responses with default reasoning", async () => { setCloudBootstrap(); const encoder = new TextEncoder(); diff --git a/src/provider/veryfront-cloud/provider.ts b/src/provider/veryfront-cloud/provider.ts index d7e410ff9e..6f3535b7ab 100644 --- a/src/provider/veryfront-cloud/provider.ts +++ b/src/provider/veryfront-cloud/provider.ts @@ -11,7 +11,10 @@ import { createVeryfrontCloudOpenAIModel, createVeryfrontCloudOpenAIResponsesModel, } from "./openai.ts"; -import { resolveVeryfrontCloudModelThinking } from "./model-catalog.ts"; +import { + resolveVeryfrontCloudModelThinking, + resolveVeryfrontCloudOpenAITransport, +} from "./model-catalog.ts"; function wrapVeryfrontCloudModel( model: ModelRuntime, @@ -51,6 +54,8 @@ function wrapVeryfrontCloudModel( } function shouldUseOpenAIResponsesRuntime(upstreamModelId: string): boolean { + const transport = resolveVeryfrontCloudOpenAITransport(`openai/${upstreamModelId}`); + if (transport !== undefined) return transport === "responses"; return resolveVeryfrontCloudModelThinking(`openai/${upstreamModelId}`)?.enabled === true; } @@ -97,6 +102,9 @@ export function createVeryfrontCloudModel(modelId: string): ModelRuntime { case "openai": { const openai = registry.get("openai"); + const openAITransport = resolveVeryfrontCloudOpenAITransport( + `openai/${upstreamModelId}`, + ); if (shouldUseOpenAIResponsesRuntime(upstreamModelId)) { if (openai?.createResponses) { return wrapVeryfrontCloudModel( @@ -127,6 +135,7 @@ export function createVeryfrontCloudModel(modelId: string): ModelRuntime { baseURL, name: "veryfront-cloud", providerName: "veryfront-cloud", + openAITransport, fetch, }), provider, @@ -136,6 +145,7 @@ export function createVeryfrontCloudModel(modelId: string): ModelRuntime { createVeryfrontCloudOpenAIModel(upstreamModelId, { apiToken, baseURL, + openAITransport, fetch, }), provider,