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:
parent
a6c36a4b6b
commit
7ea41b89ae
15 changed files with 122 additions and 14 deletions
14
packages/ai/src/model-identity.ts
Normal file
14
packages/ai/src/model-identity.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue