feat(ai,coding-agent): wireModelId — provider deployment alias

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) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-14 09:25:21 +02:00
parent a6c36a4b6b
commit 7ea41b89ae
15 changed files with 122 additions and 14 deletions

View file

@ -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<TApi extends Api>(model: Model<TApi>): string {
return model.wireModelId?.trim() || model.id;
}

View file

@ -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: {

View file

@ -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(

View file

@ -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

View file

@ -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,
};

View file

@ -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,
};

View file

@ -17,6 +17,7 @@ async function getGoogleGenAIClass(): Promise<typeof GoogleGenAI> {
}
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,
};

View file

@ -20,6 +20,7 @@ async function getMistralClass(): Promise<typeof Mistral> {
}
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")),
};

View file

@ -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,

View file

@ -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/);
});
});

View file

@ -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,
};

View file

@ -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:

View file

@ -433,7 +433,17 @@ export interface ModelCapabilities {
// Model interface for the unified model system
export interface Model<TApi extends Api> {
/**
* 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;

View file

@ -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(

View file

@ -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<Api>,
override: ModelOverride,
): Model<Api> {
const result = { ...model };
const result: Model<Api> = { ...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<OAuthProviderInterface, "id">;
models?: Array<{
id: string;
wireModelId?: string;
name: string;
api?: Api;
baseUrl?: string;