Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/coding-agent/docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ Availability depends on the selected account, workspace, and granted permissions
Token usage is reported without applying API-key token prices to subscription usage.
Review app limits and credits in [ChatGPT Settings - Usage](https://chatgpt.com/settings/usage).

When choosing a default, Diffex prefers `gpt-5.6-terra` if it is in the available ChatGPT catalog; otherwise it uses the first model in the account's catalog order. Available saved defaults and session models are preserved, not automatically upgraded.

At startup or session restoration, if a saved ChatGPT model is no longer available, Diffex reports the replacement and stays with ChatGPT while that account has available models. It falls back to another configured provider only when ChatGPT has none. These fallbacks do not rewrite your saved default; use `/model` to save a new choice.

Login selects and saves an available model when there is no current model or switching ChatGPT accounts makes the current ChatGPT model unavailable. If the newly authenticated provider has no models, login reports that instead of selecting a different provider. Logging in does not replace an available current choice or an unrelated custom model.

`/logout` stops the account's active requests, attempts remote session revocation, and clears the active tokens while retaining the host and registration for later sign-in.
If remote revocation cannot be confirmed, Diffex reports that local sign-out completed and directs you to disconnect the app in ChatGPT Settings.
An unusable refresh token clears the local token set and requires a new sign-in with the saved registration.
Expand Down
111 changes: 52 additions & 59 deletions packages/coding-agent/src/core/model-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
anthropic: "claude-opus-4-8",
openai: "gpt-5.5",
"azure-openai-responses": "gpt-5.4",
"openai-codex": "gpt-5.5",
"openai-codex": "gpt-5.6-terra",
nvidia: "nvidia/nemotron-3-super-120b-a12b",
deepseek: "deepseek-v4-pro",
google: "gemini-3.1-pro-preview",
Expand Down Expand Up @@ -61,6 +61,26 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
"xiaomi-token-plan-sgp": "mimo-v2.5-pro",
};

/**
* Select a preferred model only from the supplied available catalog.
* Provider-scoped fallback preserves catalog order, including ChatGPT's account-specific order.
*/
export function findDefaultModel(availableModels: readonly Model<Api>[], provider?: string): Model<Api> | undefined {
if (provider !== undefined) {
const providerModels = availableModels.filter((model) => model.provider === provider);
return (
providerModels.find((model) => model.id === defaultModelPerProvider[provider as KnownProvider]) ??
providerModels[0]
);
}

for (const [providerId, defaultId] of Object.entries(defaultModelPerProvider)) {
const match = availableModels.find((model) => model.provider === providerId && model.id === defaultId);
if (match) return match;
}
return availableModels[0];
}

export interface ScopedModel {
model: Model<Api>;
/** Thinking level if explicitly specified in pattern (e.g., "model:high"), undefined otherwise */
Expand Down Expand Up @@ -173,13 +193,8 @@ export interface ParsedModelResult {
}

function buildFallbackModel(provider: string, modelId: string, availableModels: Model<Api>[]): Model<Api> | undefined {
const providerModels = availableModels.filter((m) => m.provider === provider);
if (providerModels.length === 0) return undefined;

const defaultId = defaultModelPerProvider[provider as KnownProvider];
const baseModel = defaultId
? (providerModels.find((m) => m.id === defaultId) ?? providerModels[0])
: providerModels[0];
const baseModel = findDefaultModel(availableModels, provider);
if (!baseModel) return undefined;

return {
...baseModel,
Expand Down Expand Up @@ -645,7 +660,7 @@ export async function findInitialModel(options: {
} = options;

let model: Model<Api> | undefined;
let thinkingLevel: ThinkingLevel = DEFAULT_THINKING_LEVEL;
const thinkingLevel: ThinkingLevel = DEFAULT_THINKING_LEVEL;

// 1. CLI args take priority
if (cliProvider && cliModel) {
Expand Down Expand Up @@ -676,36 +691,32 @@ export async function findInitialModel(options: {
const removedDefault = defaultProvider ? getRemovedProviderMessage(defaultProvider) : undefined;
if (removedDefault)
throw new ModelsError("provider", `${removedDefault} Update defaultProvider and defaultModel in settings.json.`);
if (defaultProvider && defaultModelId) {
const found = modelRuntime.getModel(defaultProvider, defaultModelId);
if (found && modelRuntime.hasConfiguredAuth(found.provider)) {
model = found;
if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
}
return { model, thinkingLevel, fallbackMessage: undefined };
}
}

// 4. Try first available model with valid API key
const availableModels = [...modelRuntime.getAvailableSnapshot()];

if (availableModels.length > 0) {
// Try to find a default model from known providers
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
if (match) {
return { model: match, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}
const availableModels = modelRuntime.getAvailableSnapshot();
if (defaultProvider) {
const found = availableModels.find((entry) => entry.provider === defaultProvider && entry.id === defaultModelId);
if (found) {
return {
model: found,
thinkingLevel: defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL,
fallbackMessage: undefined,
};
}

// If no default found, use first available
return { model: availableModels[0], thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
// A retired or account-restricted saved model should not switch providers when
// the selected provider still has usable models. Never synthesize a model ID here.
model = findDefaultModel(availableModels, defaultProvider);
}

// 5. No model found
return { model: undefined, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
// 4. Fall back across providers only when the saved provider has no available models.
model ??= findDefaultModel(availableModels);
if (model && defaultProvider && defaultModelId) {
return {
model,
thinkingLevel: defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL,
fallbackMessage: `Saved default model ${defaultProvider}/${defaultModelId} is unavailable. Using ${model.provider}/${model.id}.`,
};
}
return { model, thinkingLevel, fallbackMessage: undefined };
}

/**
Expand All @@ -720,20 +731,18 @@ export async function restoreModelFromSession(
): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
const removed = getRemovedProviderMessage(savedProvider);
if (removed) throw new ModelsError("provider", `${removed} Resume with an explicit --model provider/model.`);
const restoredModel = modelRuntime.getModel(savedProvider, savedModelId);

// Check if restored model exists and still has auth configured
const hasConfiguredAuth = restoredModel ? modelRuntime.hasConfiguredAuth(restoredModel.provider) : false;
const availableModels = modelRuntime.getAvailableSnapshot();
const restoredModel = availableModels.find((model) => model.provider === savedProvider && model.id === savedModelId);

if (restoredModel && hasConfiguredAuth) {
if (restoredModel) {
if (shouldPrintMessages) {
console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`));
}
return { model: restoredModel, fallbackMessage: undefined };
}

// Model not found or no API key - fall back
const reason = !restoredModel ? "model no longer exists" : "no auth configured";
const reason = !modelRuntime.hasConfiguredAuth(savedProvider) ? "no auth configured" : "model is unavailable";

if (shouldPrintMessages) {
console.error(chalk.yellow(`Warning: Could not restore model ${savedProvider}/${savedModelId} (${reason}).`));
Expand All @@ -751,25 +760,9 @@ export async function restoreModelFromSession(
}

// Try to find any available model
const availableModels = [...modelRuntime.getAvailableSnapshot()];

if (availableModels.length > 0) {
// Try to find a default model from known providers
let fallbackModel: Model<Api> | undefined;
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
if (match) {
fallbackModel = match;
break;
}
}

// If no default found, use first available
if (!fallbackModel) {
fallbackModel = availableModels[0];
}
const fallbackModel = findDefaultModel(availableModels, savedProvider) ?? findDefaultModel(availableModels);

if (fallbackModel) {
if (shouldPrintMessages) {
console.log(chalk.dim(`Falling back to: ${fallbackModel.provider}/${fallbackModel.id}`));
}
Expand Down
19 changes: 13 additions & 6 deletions packages/coding-agent/src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
} from "./harness-state-runtime.ts";
import { createLessonReportContinuation } from "./lesson-reports.ts";
import { convertToLlm } from "./messages.ts";
import { findInitialModel } from "./model-resolver.ts";
import { findDefaultModel, findInitialModel } from "./model-resolver.ts";
import { ModelRuntime } from "./model-runtime.ts";
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
import type { ResourceLoader } from "./resource-loader.ts";
Expand Down Expand Up @@ -154,7 +154,7 @@ export interface CreateAgentSessionResult {
session: AgentSession;
/** Extensions result (for UI context setup in interactive mode) */
extensionsResult: LoadExtensionsResult;
/** Warning if session was restored with a different model than saved */
/** Warning if a saved session model or default could not be selected. */
modelFallbackMessage?: string;
/** Non-fatal warning when optional harness state could not be activated. */
harnessStateWarning?: string;
Expand Down Expand Up @@ -484,12 +484,17 @@ export async function createInternalAgentSession(
if (!model && hasExistingSession && existingSession.model) {
const removed = getRemovedProviderMessage(existingSession.model.provider);
if (removed) throw new ModelsError("provider", `${removed} Resume with an explicit --model provider/model.`);
const restoredModel = modelRuntime.getModel(existingSession.model.provider, existingSession.model.modelId);
if (restoredModel && modelRuntime.hasConfiguredAuth(restoredModel.provider)) {
model = restoredModel;
}
const savedModel = existingSession.model;
const availableModels = modelRuntime.getAvailableSnapshot();
model = availableModels.find(
(candidate) => candidate.provider === savedModel.provider && candidate.id === savedModel.modelId,
);
if (!model) {
model = findDefaultModel(availableModels, existingSession.model.provider);
modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
if (model) {
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
}
}
}

Expand All @@ -508,6 +513,8 @@ export async function createInternalAgentSession(
modelFallbackMessage = formatNoModelsAvailableMessage();
} else if (modelFallbackMessage) {
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
} else {
modelFallbackMessage = result.fallbackMessage;
}
}

Expand Down
42 changes: 19 additions & 23 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/htt
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
import { createCompactionSummaryMessage } from "../../core/messages.ts";
import {
defaultModelPerProvider,
findDefaultModel,
findExactModelReferenceMatch,
resolveModelScopeFromModels,
} from "../../core/model-resolver.ts";
Expand Down Expand Up @@ -302,10 +302,6 @@ export function formatResumeCommand(sessionManager: SessionManager): string | un
return args.join(" ");
}

function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider {
return providerId in defaultModelPerProvider;
}

type LoginProviderCompletionOption = {
id: string;
name: string;
Expand Down Expand Up @@ -6454,26 +6450,26 @@ export class InteractiveMode {

let selectedModel: Model<any> | undefined;
let selectionError: string | undefined;
if (isUnknownModel(previousModel)) {
const availableModels = this.session.modelRuntime.getAvailableSnapshot();
const providerModels = availableModels.filter((model) => model.provider === providerId);
if (!hasDefaultModelProvider(providerId)) {
selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`;
} else if (providerModels.length === 0) {
const availableModels = this.session.modelRuntime.getAvailableSnapshot();
if (
!previousModel ||
isUnknownModel(previousModel) ||
(previousModel.provider === providerId &&
providerId === "openai-codex" &&
!availableModels.some(
(model) => model.provider === previousModel.provider && model.id === previousModel.id,
))
) {
selectedModel = findDefaultModel(availableModels, providerId);
if (!selectedModel) {
selectionError = `${actionLabel}, but no models are available for that provider. Use /model to select a model.`;
} else {
const defaultModelId = defaultModelPerProvider[providerId];
selectedModel = providerModels.find((model) => model.id === defaultModelId);
if (!selectedModel) {
selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`;
} else {
try {
await this.session.setModel(selectedModel);
} catch (error: unknown) {
selectedModel = undefined;
const errorMessage = error instanceof Error ? error.message : String(error);
selectionError = `${actionLabel}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`;
}
try {
await this.session.setModel(selectedModel);
} catch (error: unknown) {
selectedModel = undefined;
const errorMessage = error instanceof Error ? error.message : String(error);
selectionError = `${actionLabel}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`;
}
}
}
Expand Down
Loading
Loading