Skip to content

Commit 9388550

Browse files
RomneyDaclaude
andauthored
fix(reply): clear 'model unavailable' reply + correct operator hint for retired runtime models (#97611)
* fix(models): don't advise models.providers[] registration for runtime-bound models When an agent's configured model is bound to an agent runtime (e.g. the "codex" runtime, whose catalog comes from the OpenAI ChatGPT-account app-server), and that model id is no longer offered by the runtime, model resolution fails with "Unknown model: <provider>/<model>". The appended hint told users to register the model in models.providers[].models[]. For runtime-owned models that advice is misleading: adding the registration makes resolution "succeed" only for the request to be rejected later by the provider — e.g. OpenAI returns 400 "model is not supported when using Codex with a ChatGPT account" once a model id such as gpt-5.3-codex is deprecated. Detect the agentRuntime binding on the configured agents.defaults.models entry and instead point the user at the runtime's live catalog (openclaw models list --provider <id>) and at switching the configured default to an available model. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(reply): show a clear "model unavailable" reply instead of generic failure When a configured model is retired/renamed by the provider, model resolution fails (run.ts throws a typed FailoverError with reason "model_not_found") and the agent runner falls back to the generic "Something went wrong … use /new" reply. That copy is actively misleading for this failure: retrying or starting a new session can never help, because the model id itself must be changed in config. Users on a deprecated model (e.g. gpt-5.3-codex on a Codex ChatGPT account) just see the generic message on every message and on /new. Classify this failure into a dedicated user-facing reply that explains the model is unavailable and points at the config. Detection is structural — it keys off the typed FailoverError reason, not provider error text — so it stays robust as provider wording changes; free-text rejections without a typed reason remain the responsibility of the failover layer that owns error classification. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent b28fcfe commit 9388550

4 files changed

Lines changed: 93 additions & 4 deletions

File tree

src/agents/embedded-agent-runner/model.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2832,6 +2832,29 @@ describe("resolveModel", () => {
28322832
);
28332833
});
28342834

2835+
it("points runtime-bound model entries at the runtime catalog instead of provider registration", async () => {
2836+
const cfg = {
2837+
agents: {
2838+
defaults: {
2839+
models: {
2840+
"openai/gpt-5.3-codex": {
2841+
agentRuntime: { id: "codex" },
2842+
},
2843+
},
2844+
},
2845+
},
2846+
} as unknown as OpenClawConfig;
2847+
2848+
const result = await resolveModelAsync("openai", "gpt-5.3-codex", "/tmp/agent", cfg, {
2849+
runtimeHooks: createRuntimeHooks(),
2850+
skipAgentDiscovery: true,
2851+
});
2852+
2853+
expect(result.error).toBe(
2854+
'Unknown model: openai/gpt-5.3-codex. Found agents.defaults.models["openai/gpt-5.3-codex"] bound to the "codex" agent runtime. Models served by an agent runtime come from that runtime and its linked account, not from models.providers["openai"].models[] — registering it there will not make it usable. Confirm "gpt-5.3-codex" is still offered by the "codex" runtime and switch agents.defaults.model.primary to a currently available model (run `openclaw models list --provider openai` to list them). See https://docs.openclaw.ai/concepts/model-providers.',
2855+
);
2856+
});
2857+
28352858
it("repairs stale text-only Foundry fallback rows for GPT-family models", () => {
28362859
const cfg = {
28372860
models: {

src/agents/embedded-agent-runner/model.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,12 +1913,22 @@ function buildMissingProviderModelRegistrationHint(params: {
19131913
return undefined;
19141914
}
19151915
const agentModelKey = modelKey(params.provider, params.modelId);
1916-
if (
1917-
!configuredModels[agentModelKey] &&
1918-
!configuredModels[`${params.provider}/${params.modelId}`]
1919-
) {
1916+
const configuredEntry =
1917+
configuredModels[agentModelKey] ?? configuredModels[`${params.provider}/${params.modelId}`];
1918+
if (!configuredEntry) {
19201919
return undefined;
19211920
}
1921+
// Models bound to an agent runtime (e.g. "codex") draw their catalog from that
1922+
// runtime and its linked account, not from models.providers[].models[].
1923+
// Advising a models.providers[] registration here is actively misleading: it
1924+
// makes resolution "succeed" only for the request to be rejected later by the
1925+
// runtime/provider (e.g. OpenAI returns 400 "model is not supported when using
1926+
// Codex with a ChatGPT account" once a deprecated model id is no longer
1927+
// offered). Point the user at the runtime's live catalog instead.
1928+
const agentRuntimeId = configuredEntry.agentRuntime?.id;
1929+
if (agentRuntimeId) {
1930+
return `Found agents.defaults.models["${agentModelKey}"] bound to the "${agentRuntimeId}" agent runtime. Models served by an agent runtime come from that runtime and its linked account, not from models.providers["${params.provider}"].models[] — registering it there will not make it usable. Confirm "${params.modelId}" is still offered by the "${agentRuntimeId}" runtime and switch agents.defaults.model.primary to a currently available model (run \`openclaw models list --provider ${params.provider}\` to list them). See https://docs.openclaw.ai/concepts/model-providers.`;
1931+
}
19221932
const providerConfig = findNormalizedProviderValue(
19231933
params.cfg?.models?.providers,
19241934
params.provider,

src/auto-reply/reply/provider-request-error-classifier.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE,
77
PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
88
PROVIDER_INTERNAL_ERROR_USER_MESSAGE,
9+
PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE,
910
PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE,
1011
} from "./provider-request-error-classifier.js";
1112

@@ -55,6 +56,41 @@ describe("provider request error classifier", () => {
5556
).toBeUndefined();
5657
});
5758

59+
it("classifies typed model_not_found failover errors as model unavailable", () => {
60+
const error = new FailoverError(
61+
'Unknown model: openai/gpt-5.3-codex. Found agents.defaults.models["openai/gpt-5.3-codex"] bound to the "codex" agent runtime.',
62+
{
63+
reason: "model_not_found",
64+
provider: "openai",
65+
model: "gpt-5.3-codex",
66+
},
67+
);
68+
69+
expect(classifyProviderRequestError(error)).toEqual({
70+
code: "provider_model_unavailable",
71+
userMessage: PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE,
72+
technicalMessage: error.message,
73+
});
74+
});
75+
76+
it("does not classify model-not-found from raw provider text alone", () => {
77+
// Detection is structural (typed failover reason), not text matching: a
78+
// bare error string without the typed reason is left unclassified.
79+
expect(
80+
classifyProviderRequestError(new Error("Unknown model: openai/gpt-5.3-codex")),
81+
).toBeUndefined();
82+
});
83+
84+
it("does not misclassify other typed failover reasons as model unavailable", () => {
85+
const error = new FailoverError("Provider overloaded.", {
86+
reason: "overloaded",
87+
provider: "openai",
88+
model: "gpt-5.5",
89+
});
90+
91+
expect(classifyProviderRequestError(error)).toBeUndefined();
92+
});
93+
5894
it.each([
5995
[
6096
"OpenAI missing custom tool output",

src/auto-reply/reply/provider-request-error-classifier.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type ProviderRequestErrorCode =
1212
| "provider_authentication_error"
1313
| "provider_conversation_state_error"
1414
| "provider_internal_error"
15+
| "provider_model_unavailable"
1516
| "provider_rate_limit_or_quota_error";
1617

1718
/** Structured provider error classification for reply failure handling. */
@@ -33,6 +34,14 @@ export const PROVIDER_INTERNAL_ERROR_USER_MESSAGE =
3334

3435
export const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`;
3536

37+
/**
38+
* User-facing copy for a configured model the provider no longer serves.
39+
* Distinct from generic failures because retrying or starting a new session
40+
* cannot help: the model id itself must be changed in config.
41+
*/
42+
export const PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE =
43+
"⚠️ The configured model is unavailable from the provider — it may have been renamed, retired, or is not offered on this account. This needs a config update (agents.defaults.model); retrying or starting a new session won't fix it.";
44+
3645
/** Classifies provider request failures that are actionable for users. */
3746
export function classifyProviderRequestError(
3847
err: unknown,
@@ -49,6 +58,17 @@ export function classifyProviderRequestError(
4958
technicalMessage,
5059
};
5160
}
61+
// Detect retired/unavailable models structurally via the typed failover
62+
// reason set at resolution time (run.ts), not by matching provider error
63+
// text. Free-text provider rejections without a typed reason are left to the
64+
// failover layer that owns error classification.
65+
if (isFailoverError(err) && err.reason === "model_not_found") {
66+
return {
67+
code: "provider_model_unavailable",
68+
userMessage: PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE,
69+
technicalMessage,
70+
};
71+
}
5272
if (
5373
hasHttp429Evidence(err, technicalMessage) &&
5474
isGenericProviderRuntimeErrorMessage(technicalMessage)

0 commit comments

Comments
 (0)