From 7ea41b89aee6fd52b1c1d3b05c845793c9e1976d Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 14 May 2026 09:25:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai,coding-agent):=20wireModelId=20?= =?UTF-8?q?=E2=80=94=20provider=20deployment=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional wireModelId field to the Model interface and a resolveWireModelId helper. Forge's canonical model.id stays stable for selection, capability scoring, policy, and history; providers now send model.wireModelId on the wire when set, model.id otherwise. Use cases: Azure deployment names, vendor model slugs that differ from Forge's canonical identity, A/B routing where the operator wants canonical history but a specific deployment. Wired through every provider in @singularity-forge/ai (anthropic, amazon-bedrock, azure-openai-responses, google, google-vertex, google-gemini-cli, mistral, openai-codex-responses, openai-completions, openai-responses) plus @singularity-forge/coding-agent's ModelRegistry (model definitions + per-model overrides). Tests: openai-completions wireModelId payload coverage + model-registry-auth-mode coverage for the override + definition fields. Full pi-ai + coding-agent suite: 956/956 ✓ (7 unrelated skipped). This realizes the model-registry contract drafted in 1d753af6b. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai/src/model-identity.ts | 14 ++++++ packages/ai/src/providers/amazon-bedrock.ts | 3 +- packages/ai/src/providers/anthropic-shared.ts | 3 +- .../src/providers/azure-openai-responses.ts | 3 +- .../ai/src/providers/google-gemini-cli.ts | 3 +- packages/ai/src/providers/google-vertex.ts | 3 +- packages/ai/src/providers/google.ts | 3 +- packages/ai/src/providers/mistral.ts | 3 +- .../src/providers/openai-codex-responses.ts | 7 +-- .../src/providers/openai-completions.test.ts | 46 ++++++++++++++++++- .../ai/src/providers/openai-completions.ts | 3 +- packages/ai/src/providers/openai-responses.ts | 3 +- packages/ai/src/types.ts | 10 ++++ .../src/core/model-registry-auth-mode.test.ts | 23 ++++++++++ .../coding-agent/src/core/model-registry.ts | 9 +++- 15 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 packages/ai/src/model-identity.ts diff --git a/packages/ai/src/model-identity.ts b/packages/ai/src/model-identity.ts new file mode 100644 index 000000000..c135caf5b --- /dev/null +++ b/packages/ai/src/model-identity.ts @@ -0,0 +1,14 @@ +import type { Api, Model } from "./types.js"; + +/** + * Return the provider-facing model identifier for outbound API payloads. + * + * Purpose: keep Forge's canonical model ID stable for selection, capability + * scoring, policy, and history while allowing provider endpoints to receive a + * deployment-specific wire model name. + * + * Consumer: pi-ai provider implementations when building request payloads. + */ +export function resolveWireModelId(model: Model): string { + return model.wireModelId?.trim() || model.id; +} diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 0ed13442e..459030e46 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -20,6 +20,7 @@ import { ToolResultStatus, } from "@aws-sdk/client-bedrock-runtime"; +import { resolveWireModelId } from "../model-identity.js"; import { calculateCost } from "../models.js"; import type { Api, @@ -166,7 +167,7 @@ export const streamBedrock: StreamFunction< const cacheRetention = resolveCacheRetention(options.cacheRetention); let commandInput = { - modelId: model.id, + modelId: resolveWireModelId(model), messages: convertMessages(context, model, cacheRetention), system: buildSystemPrompt(context.systemPrompt, model, cacheRetention), inferenceConfig: { diff --git a/packages/ai/src/providers/anthropic-shared.ts b/packages/ai/src/providers/anthropic-shared.ts index 161be6ed8..748a49e09 100644 --- a/packages/ai/src/providers/anthropic-shared.ts +++ b/packages/ai/src/providers/anthropic-shared.ts @@ -11,6 +11,7 @@ import type { ServerToolUseBlockParam, WebSearchToolResultBlockParam, } from "@anthropic-ai/sdk/resources/messages.js"; +import { resolveWireModelId } from "../model-identity.js"; import { calculateCost } from "../models.js"; import type { Api, @@ -508,7 +509,7 @@ export function buildParams( model.baseUrl, options?.cacheRetention, ); - const apiModelId = model.id.replace(/\[.*\]$/, ""); + const apiModelId = resolveWireModelId(model).replace(/\[.*\]$/, ""); const params: MessageCreateParamsStreaming = { model: apiModelId, messages: convertMessages( diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index c7b9003c1..ac6013bcf 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -3,6 +3,7 @@ import type { AzureOpenAI } from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; import { getEnvApiKey } from "../env-api-keys.js"; +import { resolveWireModelId } from "../model-identity.js"; import { supportsXhigh } from "../models.js"; import type { Context, @@ -72,7 +73,7 @@ function resolveDeploymentName( const mappedDeployment = parseDeploymentNameMap( process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP, ).get(model.id); - return mappedDeployment || model.id; + return mappedDeployment || resolveWireModelId(model); } // Azure OpenAI Responses-specific options diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index af9fea4db..72b16bac3 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -13,6 +13,7 @@ import type { ThinkingConfig, } from "@google/genai"; import { createGeminiCliContentGenerator } from "@singularity-forge/google-gemini-cli-provider"; +import { resolveWireModelId } from "../model-identity.js"; import { calculateCost } from "../models.js"; import type { Api, @@ -596,7 +597,7 @@ function buildRequest( } return { - model: model.id, + model: resolveWireModelId(model), contents, config, }; diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index db2642eee..d19d60a7f 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -6,6 +6,7 @@ import type { GoogleGenAI, ThinkingConfig, } from "@google/genai"; +import { resolveWireModelId } from "../model-identity.js"; import { calculateCost } from "../models.js"; import type { Api, @@ -505,7 +506,7 @@ function buildParams( } const params: GenerateContentParameters = { - model: model.id, + model: resolveWireModelId(model), contents, config, }; diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 4a36509dd..7a772fc1d 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -17,6 +17,7 @@ async function getGoogleGenAIClass(): Promise { } import { getEnvApiKey } from "../env-api-keys.js"; +import { resolveWireModelId } from "../model-identity.js"; import { calculateCost } from "../models.js"; import type { Api, @@ -468,7 +469,7 @@ function buildParams( } const params: GenerateContentParameters = { - model: model.id, + model: resolveWireModelId(model), contents, config, }; diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index 2c4045978..fe96a2b8f 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -20,6 +20,7 @@ async function getMistralClass(): Promise { } import { getEnvApiKey } from "../env-api-keys.js"; +import { resolveWireModelId } from "../model-identity.js"; import { calculateCost } from "../models.js"; import type { AssistantMessage, @@ -291,7 +292,7 @@ function buildChatPayload( options?: MistralOptions, ): ChatCompletionStreamRequest { const payload: ChatCompletionStreamRequest = { - model: model.id, + model: resolveWireModelId(model), stream: true, messages: toChatMessages(messages, model.input.includes("image")), }; diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 45a29e49d..49712bfa4 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -1,4 +1,5 @@ import { supportsXhigh } from "../models.js"; +import { resolveWireModelId } from "../model-identity.js"; import type { Api, AssistantMessage, @@ -478,7 +479,7 @@ function buildThreadStartParams( options?: OpenAICodexResponsesOptions, ): JsonObject { const params: JsonObject = { - model: model.id, + model: resolveWireModelId(model), cwd: process.cwd(), baseInstructions: context.systemPrompt ?? null, approvalPolicy: "never", @@ -501,7 +502,7 @@ function buildTurnStartParams( threadId, input, cwd: process.cwd(), - model: model.id, + model: resolveWireModelId(model), effort: options?.reasoningEffort ? clampReasoningEffort(model.id, options.reasoningEffort) : null, @@ -524,7 +525,7 @@ function buildConfig( model: Model<"openai-codex-responses">, options?: OpenAICodexResponsesOptions, ): JsonObject { - const config: JsonObject = { model: model.id }; + const config: JsonObject = { model: resolveWireModelId(model) }; if (options?.reasoningEffort) config.model_reasoning_effort = clampReasoningEffort( model.id, diff --git a/packages/ai/src/providers/openai-completions.test.ts b/packages/ai/src/providers/openai-completions.test.ts index 6042f5eed..223a34c87 100644 --- a/packages/ai/src/providers/openai-completions.test.ts +++ b/packages/ai/src/providers/openai-completions.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { describe, it } from "vitest"; import type { Context, Model, OpenAICompletionsCompat } from "../types.js"; -import { convertMessages } from "./openai-completions.js"; +import { resolveWireModelId } from "../model-identity.js"; +import { + convertMessages, + streamOpenAICompletions, +} from "./openai-completions.js"; const compat = { supportsDeveloperRole: false, @@ -73,3 +77,43 @@ describe("convertMessages cache_control", () => { assert.equal((content[0] as any).cache_control, undefined); }); }); + +describe("wire model identity", () => { + it("resolveWireModelId_when_wireModelId_missing_returns_canonical_id", () => { + const selected = model("custom", "canonical-model"); + + assert.equal(resolveWireModelId(selected), "canonical-model"); + }); + + it("openai_payload_when_wireModelId_set_sends_wire_id_and_preserves_canonical_history", async () => { + const selected = { + ...model("custom", "canonical-model"), + wireModelId: "provider-deployment", + }; + let payloadModel: unknown; + let observedModelId: string | undefined; + + const stream = streamOpenAICompletions( + selected, + { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }, + { + apiKey: "test-key", + onPayload: (payload, observedModel) => { + payloadModel = (payload as { model?: unknown }).model; + observedModelId = observedModel.id; + throw new Error("stop before network"); + }, + }, + ); + + const result = await stream.result(); + + assert.equal(payloadModel, "provider-deployment"); + assert.equal(observedModelId, "canonical-model"); + assert.equal(result.model, "canonical-model"); + assert.equal(result.stopReason, "error"); + assert.match(result.errorMessage ?? "", /stop before network/); + }); +}); diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 3e6583b75..2cb49514a 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -13,6 +13,7 @@ import type { } from "openai/resources/chat/completions.js"; import type { FunctionParameters } from "openai/resources/shared.js"; import { getEnvApiKey } from "../env-api-keys.js"; +import { resolveWireModelId } from "../model-identity.js"; import { calculateCost, supportsXhigh } from "../models.js"; import type { Context, @@ -390,7 +391,7 @@ function buildParams( maybeAddOpenRouterAnthropicCacheControl(model, messages); const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: model.id, + model: resolveWireModelId(model), messages, stream: true, }; diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 85840d327..c0364d272 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -2,6 +2,7 @@ // This avoids penalizing users who don't use OpenAI models. import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; import { getEnvApiKey } from "../env-api-keys.js"; +import { resolveWireModelId } from "../model-identity.js"; import { supportsXhigh } from "../models.js"; import type { CacheRetention, @@ -172,7 +173,7 @@ function buildParams( const cacheRetention = resolveCacheRetention(options?.cacheRetention); const params: ResponseCreateParamsStreaming = { - model: model.id, + model: resolveWireModelId(model), input: messages, stream: true, prompt_cache_key: diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e07344bd3..5e226ac68 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -433,7 +433,17 @@ export interface ModelCapabilities { // Model interface for the unified model system export interface Model { + /** + * Canonical model identity used by Forge for selection, capability scoring, + * policy, history, and user-facing display. + */ id: string; + /** + * Provider-facing model/deployment name sent on the wire. Defaults to `id`. + * Use this when the provider endpoint expects a deployment alias or vendor + * model slug that differs from Forge's canonical identity. + */ + wireModelId?: string; name: string; api: TApi; provider: Provider; diff --git a/packages/coding-agent/src/core/model-registry-auth-mode.test.ts b/packages/coding-agent/src/core/model-registry-auth-mode.test.ts index bb327ce63..98d2a046b 100644 --- a/packages/coding-agent/src/core/model-registry-auth-mode.test.ts +++ b/packages/coding-agent/src/core/model-registry-auth-mode.test.ts @@ -210,6 +210,29 @@ describe("ModelRegistry authMode — registration", () => { }); }); + it("registerProvider_when_wireModelId_set_preserves_canonical_and_wire_id", () => { + const registry = createRegistry(); + const spy = createStreamSpy(); + registry.registerProvider("wire-provider", { + authMode: "externalCli", + baseUrl: "https://wire.local", + api: "openai-completions", + streamSimple: spy.streamSimple, + models: [ + { + ...createProviderModel("canonical-model"), + wireModelId: "provider-deployment", + }, + ], + }); + + const model = findModel(registry, "wire-provider", "canonical-model"); + + assert.ok(model); + assert.equal(model.id, "canonical-model"); + assert.equal(model.wireModelId, "provider-deployment"); + }); + it("rejects apiKey provider without apiKey or oauth — message mentions authMode", () => { const registry = createRegistry(); assert.throws( diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 826f2a973..ddbf7bed0 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -186,6 +186,7 @@ const OpenAICompatSchema = Type.Union([ // Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.) const ModelDefinitionSchema = Type.Object({ id: Type.String({ minLength: 1 }), + wireModelId: Type.Optional(Type.String({ minLength: 1 })), name: Type.Optional(Type.String({ minLength: 1 })), api: Type.Optional(Type.String({ minLength: 1 })), baseUrl: Type.Optional(Type.String({ minLength: 1 })), @@ -209,6 +210,7 @@ const ModelDefinitionSchema = Type.Object({ // Schema for per-model overrides (all fields optional, merged with built-in model) const ModelOverrideSchema = Type.Object({ + wireModelId: Type.Optional(Type.String({ minLength: 1 })), name: Type.Optional(Type.String({ minLength: 1 })), reasoning: Type.Optional(Type.Boolean()), input: Type.Optional( @@ -430,9 +432,11 @@ function applyModelOverride( model: Model, override: ModelOverride, ): Model { - const result = { ...model }; + const result: Model = { ...model }; // Simple field overrides + if (override.wireModelId !== undefined) + result.wireModelId = override.wireModelId; if (override.name !== undefined) result.name = override.name; if (override.reasoning !== undefined) result.reasoning = override.reasoning; if (override.input !== undefined) @@ -851,6 +855,7 @@ export class ModelRegistry { }; models.push({ id: modelDef.id, + wireModelId: modelDef.wireModelId, name: modelDef.name ?? modelDef.id, api: api as Api, provider: providerName, @@ -1164,6 +1169,7 @@ export class ModelRegistry { this.models.push({ id: modelDef.id, + wireModelId: modelDef.wireModelId, name: modelDef.name, api: api as Api, provider: providerName, @@ -1476,6 +1482,7 @@ export interface ProviderConfigInput { oauth?: Omit; models?: Array<{ id: string; + wireModelId?: string; name: string; api?: Api; baseUrl?: string;