Explorar o código

feat(id): brand ProviderID and ModelID (#17110)

Kit Langton hai 1 mes
pai
achega
c45467964c

+ 3 - 2
packages/opencode/script/seed-e2e.ts

@@ -13,6 +13,7 @@ const seed = async () => {
   const { Session } = await import("../src/session")
   const { MessageID, PartID } = await import("../src/session/schema")
   const { Project } = await import("../src/project/project")
+  const { ModelID, ProviderID } = await import("../src/provider/schema")
 
   await Instance.provide({
     directory: dir,
@@ -28,8 +29,8 @@ const seed = async () => {
         time: { created: now },
         agent: "build",
         model: {
-          providerID,
-          modelID,
+          providerID: ProviderID.make(providerID),
+          modelID: ModelID.make(modelID),
         },
       }
       const part = {

+ 8 - 6
packages/opencode/src/acp/agent.ts

@@ -35,6 +35,7 @@ import { Hash } from "../util/hash"
 import { ACPSessionManager } from "./session"
 import type { ACPConfig } from "./types"
 import { Provider } from "../provider/provider"
+import { ProviderID } from "../provider/schema"
 import { Agent as AgentModule } from "../agent/agent"
 import { Installation } from "@/installation"
 import { MessageV2 } from "@/session/message-v2"
@@ -590,7 +591,7 @@ export namespace ACP {
         }
       } catch (e) {
         const error = MessageV2.fromError(e, {
-          providerID: this.config.defaultModel?.providerID ?? "unknown",
+          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
         })
         if (LoadAPIKeyError.isInstance(error)) {
           throw RequestError.authRequired()
@@ -655,7 +656,7 @@ export namespace ACP {
         return result
       } catch (e) {
         const error = MessageV2.fromError(e, {
-          providerID: this.config.defaultModel?.providerID ?? "unknown",
+          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
         })
         if (LoadAPIKeyError.isInstance(error)) {
           throw RequestError.authRequired()
@@ -700,7 +701,7 @@ export namespace ACP {
         return response
       } catch (e) {
         const error = MessageV2.fromError(e, {
-          providerID: this.config.defaultModel?.providerID ?? "unknown",
+          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
         })
         if (LoadAPIKeyError.isInstance(error)) {
           throw RequestError.authRequired()
@@ -765,7 +766,7 @@ export namespace ACP {
         return mode
       } catch (e) {
         const error = MessageV2.fromError(e, {
-          providerID: this.config.defaultModel?.providerID ?? "unknown",
+          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
         })
         if (LoadAPIKeyError.isInstance(error)) {
           throw RequestError.authRequired()
@@ -796,7 +797,7 @@ export namespace ACP {
         return result
       } catch (e) {
         const error = MessageV2.fromError(e, {
-          providerID: this.config.defaultModel?.providerID ?? "unknown",
+          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
         })
         if (LoadAPIKeyError.isInstance(error)) {
           throw RequestError.authRequired()
@@ -1666,7 +1667,8 @@ export namespace ACP {
   ): ModelOption[] {
     const includeVariants = options.includeVariants ?? false
     return providers.flatMap((provider) => {
-      const models = Provider.sort(Object.values(provider.models) as any)
+      const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = Object.values(provider.models)
+      const models = Provider.sort(unsorted)
       return models.flatMap((model) => {
         const base: ModelOption = {
           modelId: `${provider.id}/${model.id}`,

+ 3 - 2
packages/opencode/src/agent/agent.ts

@@ -1,6 +1,7 @@
 import { Config } from "../config/config"
 import z from "zod"
 import { Provider } from "../provider/provider"
+import { ModelID, ProviderID } from "../provider/schema"
 import { generateObject, streamObject, type ModelMessage } from "ai"
 import { SystemPrompt } from "../session/system"
 import { Instance } from "../project/instance"
@@ -34,8 +35,8 @@ export namespace Agent {
       permission: PermissionNext.Ruleset,
       model: z
         .object({
-          modelID: z.string(),
-          providerID: z.string(),
+          modelID: ModelID.zod,
+          providerID: ProviderID.zod,
         })
         .optional(),
       variant: z.string().optional(),

+ 3 - 2
packages/opencode/src/plugin/codex.ts

@@ -4,6 +4,7 @@ import { Installation } from "../installation"
 import { Auth, OAUTH_DUMMY_KEY } from "../auth"
 import os from "os"
 import { ProviderTransform } from "@/provider/transform"
+import { ModelID, ProviderID } from "@/provider/schema"
 import { setTimeout as sleep } from "node:timers/promises"
 
 const log = Log.create({ service: "plugin.codex" })
@@ -375,8 +376,8 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
 
         if (!provider.models["gpt-5.3-codex"]) {
           const model = {
-            id: "gpt-5.3-codex",
-            providerID: "openai",
+            id: ModelID.make("gpt-5.3-codex"),
+            providerID: ProviderID.make("openai"),
             api: {
               id: "gpt-5.3-codex",
               url: "https://chatgpt.com/backend-api/codex",

+ 6 - 5
packages/opencode/src/provider/auth.ts

@@ -6,6 +6,7 @@ import { fn } from "@/util/fn"
 import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
 import { NamedError } from "@opencode-ai/util/error"
 import { Auth } from "@/auth"
+import { ProviderID } from "./schema"
 
 export namespace ProviderAuth {
   const state = Instance.state(async () => {
@@ -53,7 +54,7 @@ export namespace ProviderAuth {
 
   export const authorize = fn(
     z.object({
-      providerID: z.string(),
+      providerID: ProviderID.zod,
       method: z.number(),
     }),
     async (input): Promise<Authorization | undefined> => {
@@ -73,7 +74,7 @@ export namespace ProviderAuth {
 
   export const callback = fn(
     z.object({
-      providerID: z.string(),
+      providerID: ProviderID.zod,
       method: z.number(),
       code: z.string().optional(),
     }),
@@ -119,7 +120,7 @@ export namespace ProviderAuth {
 
   export const api = fn(
     z.object({
-      providerID: z.string(),
+      providerID: ProviderID.zod,
       key: z.string(),
     }),
     async (input) => {
@@ -133,13 +134,13 @@ export namespace ProviderAuth {
   export const OauthMissing = NamedError.create(
     "ProviderAuthOauthMissing",
     z.object({
-      providerID: z.string(),
+      providerID: ProviderID.zod,
     }),
   )
   export const OauthCodeMissing = NamedError.create(
     "ProviderAuthOauthCodeMissing",
     z.object({
-      providerID: z.string(),
+      providerID: ProviderID.zod,
     }),
   )
 

+ 20 - 19
packages/opencode/src/provider/provider.ts

@@ -45,6 +45,7 @@ import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
 import { GoogleAuth } from "google-auth-library"
 import { ProviderTransform } from "./transform"
 import { Installation } from "../installation"
+import { ModelID, ProviderID } from "./schema"
 
 const DEFAULT_CHUNK_TIMEOUT = 120_000
 
@@ -673,8 +674,8 @@ export namespace Provider {
 
   export const Model = z
     .object({
-      id: z.string(),
-      providerID: z.string(),
+      id: ModelID.zod,
+      providerID: ProviderID.zod,
       api: z.object({
         id: z.string(),
         url: z.string(),
@@ -744,7 +745,7 @@ export namespace Provider {
 
   export const Info = z
     .object({
-      id: z.string(),
+      id: ProviderID.zod,
       name: z.string(),
       source: z.enum(["env", "config", "custom", "api"]),
       env: z.string().array(),
@@ -759,8 +760,8 @@ export namespace Provider {
 
   function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
     const m: Model = {
-      id: model.id,
-      providerID: provider.id,
+      id: ModelID.make(model.id),
+      providerID: ProviderID.make(provider.id),
       name: model.name,
       family: model.family,
       api: {
@@ -826,7 +827,7 @@ export namespace Provider {
 
   export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
     return {
-      id: provider.id,
+      id: ProviderID.make(provider.id),
       source: "custom",
       name: provider.name,
       env: provider.env ?? [],
@@ -866,11 +867,11 @@ export namespace Provider {
       const githubCopilot = database["github-copilot"]
       database["github-copilot-enterprise"] = {
         ...githubCopilot,
-        id: "github-copilot-enterprise",
+        id: ProviderID.make("github-copilot-enterprise"),
         name: "GitHub Copilot Enterprise",
         models: mapValues(githubCopilot.models, (model) => ({
           ...model,
-          providerID: "github-copilot-enterprise",
+          providerID: ProviderID.make("github-copilot-enterprise"),
         })),
       }
     }
@@ -892,7 +893,7 @@ export namespace Provider {
     for (const [providerID, provider] of configProviders) {
       const existing = database[providerID]
       const parsed: Info = {
-        id: providerID,
+        id: ProviderID.make(providerID),
         name: provider.name ?? existing?.name ?? providerID,
         env: provider.env ?? existing?.env ?? [],
         options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
@@ -908,7 +909,7 @@ export namespace Provider {
           return existingModel?.name ?? modelID
         })
         const parsedModel: Model = {
-          id: modelID,
+          id: ModelID.make(modelID),
           api: {
             id: model.id ?? existingModel?.api.id ?? modelID,
             npm:
@@ -921,7 +922,7 @@ export namespace Provider {
           },
           status: model.status ?? existingModel?.status ?? "active",
           name,
-          providerID,
+          providerID: ProviderID.make(providerID),
           capabilities: {
             temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
             reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
@@ -1356,7 +1357,7 @@ export namespace Provider {
   }
 
   const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
-  export function sort(models: Model[]) {
+  export function sort<T extends { id: string }>(models: T[]) {
     return sortBy(
       models,
       [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
@@ -1370,11 +1371,11 @@ export namespace Provider {
     if (cfg.model) return parseModel(cfg.model)
 
     const providers = await list()
-    const recent = (await Filesystem.readJson<{ recent?: { providerID: string; modelID: string }[] }>(
+    const recent = (await Filesystem.readJson<{ recent?: { providerID: ProviderID; modelID: ModelID }[] }>(
       path.join(Global.Path.state, "model.json"),
     )
       .then((x) => (Array.isArray(x.recent) ? x.recent : []))
-      .catch(() => [])) as { providerID: string; modelID: string }[]
+      .catch(() => [])) as { providerID: ProviderID; modelID: ModelID }[]
     for (const entry of recent) {
       const provider = providers[entry.providerID]
       if (!provider) continue
@@ -1395,16 +1396,16 @@ export namespace Provider {
   export function parseModel(model: string) {
     const [providerID, ...rest] = model.split("/")
     return {
-      providerID: providerID,
-      modelID: rest.join("/"),
+      providerID: ProviderID.make(providerID),
+      modelID: ModelID.make(rest.join("/")),
     }
   }
 
   export const ModelNotFoundError = NamedError.create(
     "ProviderModelNotFoundError",
     z.object({
-      providerID: z.string(),
-      modelID: z.string(),
+      providerID: ProviderID.zod,
+      modelID: ModelID.zod,
       suggestions: z.array(z.string()).optional(),
     }),
   )
@@ -1412,7 +1413,7 @@ export namespace Provider {
   export const InitError = NamedError.create(
     "ProviderInitError",
     z.object({
-      providerID: z.string(),
+      providerID: ProviderID.zod,
     }),
   )
 }

+ 26 - 0
packages/opencode/src/provider/schema.ts

@@ -0,0 +1,26 @@
+import { Schema } from "effect"
+import z from "zod"
+
+import { withStatics } from "@/util/schema"
+
+const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID"))
+
+export type ProviderID = typeof providerIdSchema.Type
+
+export const ProviderID = providerIdSchema.pipe(
+  withStatics((schema: typeof providerIdSchema) => ({
+    make: (id: string) => schema.makeUnsafe(id),
+    zod: z.string().pipe(z.custom<ProviderID>()),
+  })),
+)
+
+const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID"))
+
+export type ModelID = typeof modelIdSchema.Type
+
+export const ModelID = modelIdSchema.pipe(
+  withStatics((schema: typeof modelIdSchema) => ({
+    make: (id: string) => schema.makeUnsafe(id),
+    zod: z.string().pipe(z.custom<ModelID>()),
+  })),
+)

+ 3 - 2
packages/opencode/src/server/routes/provider.ts

@@ -5,6 +5,7 @@ import { Config } from "../../config/config"
 import { Provider } from "../../provider/provider"
 import { ModelsDev } from "../../provider/models"
 import { ProviderAuth } from "../../provider/auth"
+import { ProviderID } from "../../provider/schema"
 import { mapValues } from "remeda"
 import { errors } from "../error"
 import { lazy } from "../../util/lazy"
@@ -101,7 +102,7 @@ export const ProviderRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          providerID: z.string().meta({ description: "Provider ID" }),
+          providerID: ProviderID.zod.meta({ description: "Provider ID" }),
         }),
       ),
       validator(
@@ -141,7 +142,7 @@ export const ProviderRoutes = lazy(() =>
       validator(
         "param",
         z.object({
-          providerID: z.string().meta({ description: "Provider ID" }),
+          providerID: ProviderID.zod.meta({ description: "Provider ID" }),
         }),
       ),
       validator(

+ 3 - 2
packages/opencode/src/server/routes/session.ts

@@ -16,6 +16,7 @@ import { Snapshot } from "@/snapshot"
 import { Log } from "../../util/log"
 import { PermissionNext } from "@/permission/next"
 import { PermissionID } from "@/permission/schema"
+import { ModelID, ProviderID } from "@/provider/schema"
 import { errors } from "../error"
 import { lazy } from "../../util/lazy"
 
@@ -510,8 +511,8 @@ export const SessionRoutes = lazy(() =>
       validator(
         "json",
         z.object({
-          providerID: z.string(),
-          modelID: z.string(),
+          providerID: ProviderID.zod,
+          modelID: ModelID.zod,
           auto: z.boolean().optional().default(false),
         }),
       ),

+ 3 - 2
packages/opencode/src/server/server.ts

@@ -23,6 +23,7 @@ import { Command } from "../command"
 import { Global } from "../global"
 import { WorkspaceContext } from "../control-plane/workspace-context"
 import { WorkspaceID } from "../control-plane/schema"
+import { ProviderID } from "../provider/schema"
 import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
 import { ProjectRoutes } from "./routes/project"
 import { SessionRoutes } from "./routes/session"
@@ -148,7 +149,7 @@ export namespace Server {
         validator(
           "param",
           z.object({
-            providerID: z.string(),
+            providerID: ProviderID.zod,
           }),
         ),
         validator("json", Auth.Info),
@@ -180,7 +181,7 @@ export namespace Server {
         validator(
           "param",
           z.object({
-            providerID: z.string(),
+            providerID: ProviderID.zod,
           }),
         ),
         async (c) => {

+ 3 - 2
packages/opencode/src/session/compaction.ts

@@ -14,6 +14,7 @@ import { Agent } from "@/agent/agent"
 import { Plugin } from "@/plugin"
 import { Config } from "@/config/config"
 import { ProviderTransform } from "@/provider/transform"
+import { ModelID, ProviderID } from "@/provider/schema"
 
 export namespace SessionCompaction {
   const log = Log.create({ service: "session.compaction" })
@@ -298,8 +299,8 @@ When constructing the summary, try to stick to this template:
       sessionID: SessionID.zod,
       agent: z.string(),
       model: z.object({
-        providerID: z.string(),
-        modelID: z.string(),
+        providerID: ProviderID.zod,
+        modelID: ModelID.zod,
       }),
       auto: z.boolean(),
       overflow: z.boolean().optional(),

+ 3 - 2
packages/opencode/src/session/index.ts

@@ -27,6 +27,7 @@ import { WorkspaceID } from "../control-plane/schema"
 import { SessionID, MessageID, PartID } from "./schema"
 
 import type { Provider } from "@/provider/provider"
+import { ModelID, ProviderID } from "@/provider/schema"
 import { PermissionNext } from "@/permission/next"
 import { Global } from "@/global"
 import type { LanguageModelV2Usage } from "@ai-sdk/provider"
@@ -875,8 +876,8 @@ export namespace Session {
   export const initialize = fn(
     z.object({
       sessionID: SessionID.zod,
-      modelID: z.string(),
-      providerID: z.string(),
+      modelID: ModelID.zod,
+      providerID: ProviderID.zod,
       messageID: MessageID.zod,
     }),
     async (input) => {

+ 8 - 7
packages/opencode/src/session/message-v2.ts

@@ -15,6 +15,7 @@ import { ProviderError } from "@/provider/error"
 import { iife } from "@/util/iife"
 import { type SystemError } from "bun"
 import type { Provider } from "@/provider/provider"
+import { ModelID, ProviderID } from "@/provider/schema"
 
 export namespace MessageV2 {
   export function isMedia(mime: string) {
@@ -213,8 +214,8 @@ export namespace MessageV2 {
     agent: z.string(),
     model: z
       .object({
-        providerID: z.string(),
-        modelID: z.string(),
+        providerID: ProviderID.zod,
+        modelID: ModelID.zod,
       })
       .optional(),
     command: z.string().optional(),
@@ -362,8 +363,8 @@ export namespace MessageV2 {
       .optional(),
     agent: z.string(),
     model: z.object({
-      providerID: z.string(),
-      modelID: z.string(),
+      providerID: ProviderID.zod,
+      modelID: ModelID.zod,
     }),
     system: z.string().optional(),
     tools: z.record(z.string(), z.boolean()).optional(),
@@ -411,8 +412,8 @@ export namespace MessageV2 {
       ])
       .optional(),
     parentID: MessageID.zod,
-    modelID: z.string(),
-    providerID: z.string(),
+    modelID: ModelID.zod,
+    providerID: ProviderID.zod,
     /**
      * @deprecated
      */
@@ -824,7 +825,7 @@ export namespace MessageV2 {
     return result
   }
 
-  export function fromError(e: unknown, ctx: { providerID: string }) {
+  export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable<Assistant["error"]> {
     switch (true) {
       case e instanceof DOMException && e.name === "AbortError":
         return new MessageV2.AbortedError(

+ 3 - 2
packages/opencode/src/session/message.ts

@@ -1,5 +1,6 @@
 import z from "zod"
 import { SessionID } from "./schema"
+import { ModelID, ProviderID } from "../provider/schema"
 import { NamedError } from "@opencode-ai/util/error"
 
 export namespace Message {
@@ -160,8 +161,8 @@ export namespace Message {
           assistant: z
             .object({
               system: z.string().array(),
-              modelID: z.string(),
-              providerID: z.string(),
+              modelID: ModelID.zod,
+              providerID: ProviderID.zod,
               path: z.object({
                 cwd: z.string(),
                 root: z.string(),

+ 5 - 4
packages/opencode/src/session/prompt.ts

@@ -10,6 +10,7 @@ import { SessionRevert } from "./revert"
 import { Session } from "."
 import { Agent } from "../agent/agent"
 import { Provider } from "../provider/provider"
+import { ModelID, ProviderID } from "../provider/schema"
 import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions, asSchema } from "ai"
 import { SessionCompaction } from "./compaction"
 import { Instance } from "../project/instance"
@@ -94,8 +95,8 @@ export namespace SessionPrompt {
     messageID: MessageID.zod.optional(),
     model: z
       .object({
-        providerID: z.string(),
-        modelID: z.string(),
+        providerID: ProviderID.zod,
+        modelID: ModelID.zod,
       })
       .optional(),
     agent: z.string().optional(),
@@ -1471,8 +1472,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
     agent: z.string(),
     model: z
       .object({
-        providerID: z.string(),
-        modelID: z.string(),
+        providerID: ProviderID.zod,
+        modelID: ModelID.zod,
       })
       .optional(),
     command: z.string(),

+ 4 - 4
packages/opencode/test/agent/agent.test.ts

@@ -137,8 +137,8 @@ test("custom agent from config creates new agent", async () => {
     fn: async () => {
       const custom = await Agent.get("my_custom_agent")
       expect(custom).toBeDefined()
-      expect(custom?.model?.providerID).toBe("openai")
-      expect(custom?.model?.modelID).toBe("gpt-4")
+      expect(String(custom?.model?.providerID)).toBe("openai")
+      expect(String(custom?.model?.modelID)).toBe("gpt-4")
       expect(custom?.description).toBe("My custom agent")
       expect(custom?.temperature).toBe(0.5)
       expect(custom?.topP).toBe(0.9)
@@ -166,8 +166,8 @@ test("custom agent config overrides native agent properties", async () => {
     fn: async () => {
       const build = await Agent.get("build")
       expect(build).toBeDefined()
-      expect(build?.model?.providerID).toBe("anthropic")
-      expect(build?.model?.modelID).toBe("claude-3")
+      expect(String(build?.model?.providerID)).toBe("anthropic")
+      expect(String(build?.model?.modelID)).toBe("claude-3")
       expect(build?.description).toBe("Custom build agent")
       expect(build?.temperature).toBe(0.7)
       expect(build?.color).toBe("#FF0000")

+ 12 - 12
packages/opencode/test/provider/provider.test.ts

@@ -302,8 +302,8 @@ test("getModel returns model for valid provider/model", async () => {
     fn: async () => {
       const model = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
       expect(model).toBeDefined()
-      expect(model.providerID).toBe("anthropic")
-      expect(model.id).toBe("claude-sonnet-4-20250514")
+      expect(String(model.providerID)).toBe("anthropic")
+      expect(String(model.id)).toBe("claude-sonnet-4-20250514")
       const language = await Provider.getLanguage(model)
       expect(language).toBeDefined()
     },
@@ -353,14 +353,14 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => {
 
 test("parseModel correctly parses provider/model string", () => {
   const result = Provider.parseModel("anthropic/claude-sonnet-4")
-  expect(result.providerID).toBe("anthropic")
-  expect(result.modelID).toBe("claude-sonnet-4")
+  expect(String(result.providerID)).toBe("anthropic")
+  expect(String(result.modelID)).toBe("claude-sonnet-4")
 })
 
 test("parseModel handles model IDs with slashes", () => {
   const result = Provider.parseModel("openrouter/anthropic/claude-3-opus")
-  expect(result.providerID).toBe("openrouter")
-  expect(result.modelID).toBe("anthropic/claude-3-opus")
+  expect(String(result.providerID)).toBe("openrouter")
+  expect(String(result.modelID)).toBe("anthropic/claude-3-opus")
 })
 
 test("defaultModel returns first available model when no config set", async () => {
@@ -406,8 +406,8 @@ test("defaultModel respects config model setting", async () => {
     },
     fn: async () => {
       const model = await Provider.defaultModel()
-      expect(model.providerID).toBe("anthropic")
-      expect(model.modelID).toBe("claude-sonnet-4-20250514")
+      expect(String(model.providerID)).toBe("anthropic")
+      expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
     },
   })
 })
@@ -632,7 +632,7 @@ test("getModel uses realIdByKey for aliased models", async () => {
 
       const model = await Provider.getModel("anthropic", "my-sonnet")
       expect(model).toBeDefined()
-      expect(model.id).toBe("my-sonnet")
+      expect(String(model.id)).toBe("my-sonnet")
       expect(model.name).toBe("My Sonnet Alias")
     },
   })
@@ -960,8 +960,8 @@ test("getSmallModel respects config small_model override", async () => {
     fn: async () => {
       const model = await Provider.getSmallModel("anthropic")
       expect(model).toBeDefined()
-      expect(model?.providerID).toBe("anthropic")
-      expect(model?.id).toBe("claude-sonnet-4-20250514")
+      expect(String(model?.providerID)).toBe("anthropic")
+      expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
     },
   })
 })
@@ -1605,7 +1605,7 @@ test("getProvider returns provider info", async () => {
     fn: async () => {
       const provider = await Provider.getProvider("anthropic")
       expect(provider).toBeDefined()
-      expect(provider?.id).toBe("anthropic")
+      expect(String(provider?.id)).toBe("anthropic")
     },
   })
 })

+ 5 - 4
packages/opencode/test/provider/transform.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { ProviderTransform } from "../../src/provider/transform"
+import { ModelID, ProviderID } from "../../src/provider/schema"
 
 const OUTPUT_TOKEN_MAX = 32000
 
@@ -740,8 +741,8 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
     const result = ProviderTransform.message(
       msgs,
       {
-        id: "deepseek/deepseek-chat",
-        providerID: "deepseek",
+        id: ModelID.make("deepseek/deepseek-chat"),
+        providerID: ProviderID.make("deepseek"),
         api: {
           id: "deepseek-chat",
           url: "https://api.deepseek.com",
@@ -802,8 +803,8 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
     const result = ProviderTransform.message(
       msgs,
       {
-        id: "openai/gpt-4",
-        providerID: "openai",
+        id: ModelID.make("openai/gpt-4"),
+        providerID: ProviderID.make("openai"),
         api: {
           id: "gpt-4",
           url: "https://api.openai.com",

+ 5 - 4
packages/opencode/test/session/llm.test.ts

@@ -7,6 +7,7 @@ import { Instance } from "../../src/project/instance"
 import { Provider } from "../../src/provider/provider"
 import { ProviderTransform } from "../../src/provider/transform"
 import { ModelsDev } from "../../src/provider/models"
+import { ProviderID } from "../../src/provider/schema"
 import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
 import type { Agent } from "../../src/agent/agent"
@@ -282,7 +283,7 @@ describe("session.llm.stream", () => {
           role: "user",
           time: { created: Date.now() },
           agent: agent.name,
-          model: { providerID, modelID: resolved.id },
+          model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
           variant: "high",
         } satisfies MessageV2.User
 
@@ -411,7 +412,7 @@ describe("session.llm.stream", () => {
           role: "user",
           time: { created: Date.now() },
           agent: agent.name,
-          model: { providerID: "openai", modelID: resolved.id },
+          model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
           variant: "high",
         } satisfies MessageV2.User
 
@@ -534,7 +535,7 @@ describe("session.llm.stream", () => {
           role: "user",
           time: { created: Date.now() },
           agent: agent.name,
-          model: { providerID, modelID: resolved.id },
+          model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
         } satisfies MessageV2.User
 
         const stream = await LLM.stream({
@@ -635,7 +636,7 @@ describe("session.llm.stream", () => {
           role: "user",
           time: { created: Date.now() },
           agent: agent.name,
-          model: { providerID, modelID: resolved.id },
+          model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
         } satisfies MessageV2.User
 
         const stream = await LLM.stream({

+ 10 - 8
packages/opencode/test/session/message-v2.test.ts

@@ -2,12 +2,14 @@ import { describe, expect, test } from "bun:test"
 import { APICallError } from "ai"
 import { MessageV2 } from "../../src/session/message-v2"
 import type { Provider } from "../../src/provider/provider"
+import { ModelID, ProviderID } from "../../src/provider/schema"
 import { SessionID, MessageID, PartID } from "../../src/session/schema"
 
 const sessionID = SessionID.make("session")
+const providerID = ProviderID.make("test")
 const model: Provider.Model = {
-  id: "test-model",
-  providerID: "test",
+  id: ModelID.make("test-model"),
+  providerID,
   api: {
     id: "test-model",
     url: "https://example.com",
@@ -61,7 +63,7 @@ function userInfo(id: string): MessageV2.User {
     role: "user",
     time: { created: 0 },
     agent: "user",
-    model: { providerID: "test", modelID: "test" },
+    model: { providerID, modelID: ModelID.make("test") },
     tools: {},
     mode: "",
   } as unknown as MessageV2.User
@@ -795,7 +797,7 @@ describe("session.message-v2.fromError", () => {
         code: "context_length_exceeded",
       },
     }
-    const result = MessageV2.fromError(input, { providerID: "test" })
+    const result = MessageV2.fromError(input, { providerID })
 
     expect(result).toStrictEqual({
       name: "ContextOverflowError",
@@ -830,7 +832,7 @@ describe("session.message-v2.fromError", () => {
           message: item.code === "invalid_prompt" ? item.message : undefined,
         },
       }
-      const result = MessageV2.fromError(input, { providerID: "test" })
+      const result = MessageV2.fromError(input, { providerID })
 
       expect(result).toStrictEqual({
         name: "APIError",
@@ -862,7 +864,7 @@ describe("session.message-v2.fromError", () => {
         responseHeaders: { "content-type": "application/json" },
         isRetryable: false,
       })
-      const result = MessageV2.fromError(error, { providerID: "test" })
+      const result = MessageV2.fromError(error, { providerID })
       expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true)
     })
   })
@@ -877,14 +879,14 @@ describe("session.message-v2.fromError", () => {
         responseHeaders: { "content-type": "application/json" },
         isRetryable: false,
       }),
-      { providerID: "test" },
+      { providerID },
     )
     expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(false)
     expect(MessageV2.APIError.isInstance(result)).toBe(true)
   })
 
   test("serializes unknown inputs", () => {
-    const result = MessageV2.fromError(123, { providerID: "test" })
+    const result = MessageV2.fromError(123, { providerID })
 
     expect(result).toStrictEqual({
       name: "UnknownError",

+ 3 - 2
packages/opencode/test/session/prompt.test.ts

@@ -2,6 +2,7 @@ import path from "path"
 import { describe, expect, test } from "bun:test"
 import { fileURLToPath } from "url"
 import { Instance } from "../../src/project/instance"
+import { ModelID, ProviderID } from "../../src/provider/schema"
 import { Session } from "../../src/session"
 import { MessageV2 } from "../../src/session/message-v2"
 import { SessionPrompt } from "../../src/session/prompt"
@@ -173,7 +174,7 @@ describe("session.prompt agent variant", () => {
           const other = await SessionPrompt.prompt({
             sessionID: session.id,
             agent: "build",
-            model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
+            model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
             noReply: true,
             parts: [{ type: "text", text: "hello" }],
           })
@@ -187,7 +188,7 @@ describe("session.prompt agent variant", () => {
             parts: [{ type: "text", text: "hello again" }],
           })
           if (match.info.role !== "user") throw new Error("expected user message")
-          expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
+          expect(match.info.model).toEqual({ providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.2") })
           expect(match.info.variant).toBe("xhigh")
 
           const override = await SessionPrompt.prompt({

+ 5 - 2
packages/opencode/test/session/retry.test.ts

@@ -4,6 +4,9 @@ import { APICallError } from "ai"
 import { setTimeout as sleep } from "node:timers/promises"
 import { SessionRetry } from "../../src/session/retry"
 import { MessageV2 } from "../../src/session/message-v2"
+import { ProviderID } from "../../src/provider/schema"
+
+const providerID = ProviderID.make("test")
 
 function apiError(headers?: Record<string, string>): MessageV2.APIError {
   return new MessageV2.APIError({
@@ -150,7 +153,7 @@ describe("session.message-v2.fromError", () => {
         .then((res) => res.text())
         .catch((e) => e)
 
-      const result = MessageV2.fromError(error, { providerID: "test" })
+      const result = MessageV2.fromError(error, { providerID })
 
       expect(MessageV2.APIError.isInstance(result)).toBe(true)
       expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
@@ -183,7 +186,7 @@ describe("session.message-v2.fromError", () => {
       responseBody: '{"error":"boom"}',
       isRetryable: false,
     })
-    const result = MessageV2.fromError(error, { providerID: "openai" }) as MessageV2.APIError
+    const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError
     expect(result.data.isRetryable).toBe(true)
   })
 })

+ 13 - 12
packages/opencode/test/session/revert-compact.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, test, beforeEach, afterEach } from "bun:test"
 import path from "path"
 import { Session } from "../../src/session"
+import { ModelID, ProviderID } from "../../src/provider/schema"
 import { SessionRevert } from "../../src/session/revert"
 import { SessionCompaction } from "../../src/session/compaction"
 import { MessageV2 } from "../../src/session/message-v2"
@@ -29,8 +30,8 @@ describe("revert + compact workflow", () => {
           sessionID,
           agent: "default",
           model: {
-            providerID: "openai",
-            modelID: "gpt-4",
+            providerID: ProviderID.make("openai"),
+            modelID: ModelID.make("gpt-4"),
           },
           time: {
             created: Date.now(),
@@ -64,8 +65,8 @@ describe("revert + compact workflow", () => {
             reasoning: 0,
             cache: { read: 0, write: 0 },
           },
-          modelID: "gpt-4",
-          providerID: "openai",
+          modelID: ModelID.make("gpt-4"),
+          providerID: ProviderID.make("openai"),
           parentID: userMsg1.id,
           time: {
             created: Date.now(),
@@ -90,8 +91,8 @@ describe("revert + compact workflow", () => {
           sessionID,
           agent: "default",
           model: {
-            providerID: "openai",
-            modelID: "gpt-4",
+            providerID: ProviderID.make("openai"),
+            modelID: ModelID.make("gpt-4"),
           },
           time: {
             created: Date.now(),
@@ -124,8 +125,8 @@ describe("revert + compact workflow", () => {
             reasoning: 0,
             cache: { read: 0, write: 0 },
           },
-          modelID: "gpt-4",
-          providerID: "openai",
+          modelID: ModelID.make("gpt-4"),
+          providerID: ProviderID.make("openai"),
           parentID: userMsg2.id,
           time: {
             created: Date.now(),
@@ -205,8 +206,8 @@ describe("revert + compact workflow", () => {
           sessionID,
           agent: "default",
           model: {
-            providerID: "openai",
-            modelID: "gpt-4",
+            providerID: ProviderID.make("openai"),
+            modelID: ModelID.make("gpt-4"),
           },
           time: {
             created: Date.now(),
@@ -238,8 +239,8 @@ describe("revert + compact workflow", () => {
             reasoning: 0,
             cache: { read: 0, write: 0 },
           },
-          modelID: "gpt-4",
-          providerID: "openai",
+          modelID: ModelID.make("gpt-4"),
+          providerID: ProviderID.make("openai"),
           parentID: userMsg.id,
           time: {
             created: Date.now(),