feat: unified provider resolver with protocol-based dispatch

Extract provider resolution logic into two new modules:
- provider-catalog.ts: vendor presets, default model catalogs, protocol definitions
- provider-resolver.ts: unified resolution for all consumers (Claude SDK, AI SDK, model listing)

DB migration:
- Add protocol, headers_json, env_overrides_json, role_models_json columns to api_providers
- Create provider_models table for per-provider model catalog

Consumer refactoring:
- claude-client.ts: replace inline env-building with toClaudeCodeEnv()
- text-generator.ts: replace inline config with toAiSdkConfig()
- conversation-engine.ts, onboarding, checkin, plan routes: use shared resolveProvider()
- /api/providers/models: read from catalog + provider_models table

UI improvements:
- Merge confusing "Custom API (Anthropic-compatible)" into "Anthropic Third-party API"
- Add 3-field model name mapping (sonnet/opus/haiku) in advanced options
- ProviderForm.tsx: support new fields (protocol, env_overrides, role_models)

Bug fixes:
- Protect auth credentials from being deleted by legacy extra_env empty-string overrides
- Fix env var names: ANTHROPIC_DEFAULT_SONNET → ANTHROPIC_DEFAULT_SONNET_MODEL (SDK convention)

Tests: 229/229 passed. Verified with aiberm (no mapping) and pipellm (with mapping).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-09 20:30:35 +08:00
1 parent 4dc787c4a4
commit f76e4747cd
21 files changed
+2932 -344

No files matched your search

+13 -21
View File
@@ -1,6 +1,7 @@
import { NextRequest } from 'next/server';
import { streamClaude } from '@/lib/claude-client';
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
import { resolveProvider as resolveProviderUnified } from '@/lib/provider-resolver';
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types';
import crypto from 'crypto';
@@ -116,26 +117,15 @@ export async function POST(request: NextRequest) {
updateSessionModel(session_id, effectiveModel);
}
// Resolve provider: explicit provider_id > default_provider_id > environment variables
let resolvedProvider: import('@/types').ApiProvider | undefined;
// Resolve provider via unified resolver (same logic for chat, bridge, onboarding, etc.)
const effectiveProviderId = provider_id || session.provider_id || '';
if (effectiveProviderId && effectiveProviderId !== 'env') {
resolvedProvider = getProvider(effectiveProviderId);
if (!resolvedProvider) {
// Requested provider not found, try default
const defaultId = getDefaultProviderId();
if (defaultId) {
resolvedProvider = getProvider(defaultId);
}
}
} else if (!effectiveProviderId) {
// No provider specified, try default
const defaultId = getDefaultProviderId();
if (defaultId) {
resolvedProvider = getProvider(defaultId);
}
}
// effectiveProviderId === 'env' → resolvedProvider stays undefined → uses env vars
const resolved = resolveProviderUnified({
providerId: effectiveProviderId || undefined,
sessionProviderId: session.provider_id || undefined,
model: model || undefined,
sessionModel: session.model || undefined,
});
const resolvedProvider = resolved.provider;
const providerName = resolvedProvider?.name || '';
if (providerName !== (session.provider_name || '')) {
@@ -329,7 +319,7 @@ Start by greeting the user and asking the first question.
prompt: content,
sessionId: session_id,
sdkSessionId: session.sdk_session_id || undefined,
model: effectiveModel,
model: resolved.upstreamModel || resolved.model || effectiveModel,
systemPrompt: finalSystemPrompt,
workingDirectory: session.sdk_cwd || session.working_directory || undefined,
abortController,
@@ -338,6 +328,8 @@ Start by greeting the user and asking the first question.
imageAgentMode: !!systemPromptAppend,
toolTimeoutSeconds: toolTimeout || 300,
provider: resolvedProvider,
providerId: effectiveProviderId || undefined,
sessionProviderId: session.provider_id || undefined,
mcpServers,
conversationHistory: historyMsgs,
bypassPermissions: session.permission_profile === 'full_access',
+12 -19
View File
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
import { streamTextFromProvider } from '@/lib/text-generator';
import { getDb } from '@/lib/db';
import { resolveProvider } from '@/lib/provider-resolver';
import fs from 'fs';
import type { PlanMediaJobRequest } from '@/types';
@@ -49,27 +49,20 @@ export async function POST(request: NextRequest) {
}
// Resolve the provider and model from the session or defaults
const db = getDb();
let providerId = '';
let modelId = '';
let session: { provider_id: string; model: string } | undefined;
if (body.sessionId) {
const session = db.prepare('SELECT provider_id, model FROM chat_sessions WHERE id = ?').get(body.sessionId) as { provider_id: string; model: string } | undefined;
if (session) {
providerId = session.provider_id;
modelId = session.model;
}
const { getDb } = await import('@/lib/db');
const db = getDb();
session = db.prepare('SELECT provider_id, model FROM chat_sessions WHERE id = ?').get(body.sessionId) as { provider_id: string; model: string } | undefined;
}
// Fallback to default provider
if (!providerId) {
const defaultId = db.prepare("SELECT value FROM settings WHERE key = 'default_provider_id'").get() as { value: string } | undefined;
providerId = defaultId?.value || '';
}
if (!modelId) {
const defaultModel = db.prepare("SELECT value FROM settings WHERE key = 'default_model'").get() as { value: string } | undefined;
modelId = defaultModel?.value || 'claude-sonnet-4-20250514';
}
const resolved = resolveProvider({
sessionProviderId: session?.provider_id || undefined,
sessionModel: session?.model || undefined,
});
// Preserve 'env' semantics (see onboarding route for rationale)
const providerId = resolved.provider?.id || 'env';
const modelId = resolved.upstreamModel || resolved.model || session?.model || 'claude-sonnet-4-20250514';
// Read document content
let docContent = body.docContent || '';
+86 -74
View File
@@ -1,74 +1,31 @@
import { NextResponse } from 'next/server';
import { getAllProviders, getDefaultProviderId } from '@/lib/db';
import { getAllProviders, getDefaultProviderId, getModelsForProvider } from '@/lib/db';
import { getContextWindow } from '@/lib/model-context';
import { getDefaultModelsForProvider, inferProtocolFromLegacy } from '@/lib/provider-catalog';
import type { Protocol } from '@/lib/provider-catalog';
import type { ErrorResponse, ProviderModelGroup } from '@/types';
// Default Claude model options
// Default Claude model options (for the built-in 'env' provider)
const DEFAULT_MODELS = [
{ value: 'sonnet', label: 'Sonnet 4.6' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'haiku', label: 'Haiku 4.5' },
];
// Provider-specific model label mappings (base_url -> alias -> display name)
const PROVIDER_MODEL_LABELS: Record<string, { value: string; label: string }[]> = {
'https://api.z.ai/api/anthropic': [
{ value: 'sonnet', label: 'GLM-4.7' },
{ value: 'opus', label: 'GLM-5' },
{ value: 'haiku', label: 'GLM-4.5-Air' },
],
'https://open.bigmodel.cn/api/anthropic': [
{ value: 'sonnet', label: 'GLM-4.7' },
{ value: 'opus', label: 'GLM-5' },
{ value: 'haiku', label: 'GLM-4.5-Air' },
],
'https://api.kimi.com/coding/': [
{ value: 'sonnet', label: 'Kimi K2.5' },
{ value: 'opus', label: 'Kimi K2.5' },
{ value: 'haiku', label: 'Kimi K2.5' },
],
'https://api.moonshot.ai/anthropic': [
{ value: 'sonnet', label: 'Kimi K2.5' },
{ value: 'opus', label: 'Kimi K2.5' },
{ value: 'haiku', label: 'Kimi K2.5' },
],
'https://api.moonshot.cn/anthropic': [
{ value: 'sonnet', label: 'Kimi K2.5' },
{ value: 'opus', label: 'Kimi K2.5' },
{ value: 'haiku', label: 'Kimi K2.5' },
],
'https://api.minimaxi.com/anthropic': [
{ value: 'sonnet', label: 'MiniMax-M2.5' },
{ value: 'opus', label: 'MiniMax-M2.5' },
{ value: 'haiku', label: 'MiniMax-M2.5' },
],
'https://api.minimax.io/anthropic': [
{ value: 'sonnet', label: 'MiniMax-M2.5' },
{ value: 'opus', label: 'MiniMax-M2.5' },
{ value: 'haiku', label: 'MiniMax-M2.5' },
],
'https://openrouter.ai/api': [
{ value: 'sonnet', label: 'Sonnet 4.6' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'haiku', label: 'Haiku 4.5' },
],
'https://coding.dashscope.aliyuncs.com/apps/anthropic': [
{ value: 'qwen3.5-plus', label: 'Qwen 3.5 Plus' },
{ value: 'qwen3-coder-next', label: 'Qwen 3 Coder Next' },
{ value: 'qwen3-coder-plus', label: 'Qwen 3 Coder Plus' },
{ value: 'kimi-k2.5', label: 'Kimi K2.5' },
{ value: 'glm-5', label: 'GLM-5' },
{ value: 'glm-4.7', label: 'GLM-4.7' },
{ value: 'MiniMax-M2.5', label: 'MiniMax-M2.5' },
],
};
interface ModelEntry {
value: string;
label: string;
upstreamModelId?: string;
capabilities?: Record<string, unknown>;
variants?: Record<string, unknown>;
}
/**
* Deduplicate models: if multiple aliases map to the same label, keep only the first one.
*/
function deduplicateModels(models: { value: string; label: string }[]): { value: string; label: string }[] {
function deduplicateModels(models: ModelEntry[]): ModelEntry[] {
const seen = new Set<string>();
const result: { value: string; label: string }[] = [];
const result: ModelEntry[] = [];
for (const m of models) {
if (!seen.has(m.label)) {
seen.add(m.label);
@@ -78,14 +35,16 @@ function deduplicateModels(models: { value: string; label: string }[]): { value:
return result;
}
/** Media-only provider protocols — skip in chat model selector */
const MEDIA_PROTOCOLS = new Set<string>(['gemini-image']);
const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']);
export async function GET() {
try {
const providers = getAllProviders();
const groups: ProviderModelGroup[] = [];
// Always show the built-in Claude Code provider group.
// Claude Code CLI stores credentials in ~/.claude/ (via `claude login`),
// which the SDK subprocess can read — even without ANTHROPIC_API_KEY in env.
groups.push({
provider_id: 'env',
provider_name: 'Claude Code',
@@ -118,29 +77,82 @@ export async function GET() {
// SDK capabilities not available, keep defaults
}
// Provider types that are not LLMs (e.g. image generation) — skip in chat model selector
const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']);
// Build a group for each configured provider
for (const provider of providers) {
if (MEDIA_PROVIDER_TYPES.has(provider.provider_type)) continue;
const matched = PROVIDER_MODEL_LABELS[provider.base_url];
let rawModels = matched || DEFAULT_MODELS;
// Determine protocol — use new field if present, otherwise infer from legacy
const protocol: Protocol = (provider.protocol as Protocol) ||
inferProtocolFromLegacy(provider.provider_type, provider.base_url);
// For providers with ANTHROPIC_MODEL in extra_env (e.g. Volcengine Ark),
// show the configured model name in the selector
if (!matched) {
try {
const envObj = JSON.parse(provider.extra_env || '{}');
if (envObj.ANTHROPIC_MODEL) {
rawModels = [{ value: envObj.ANTHROPIC_MODEL, label: envObj.ANTHROPIC_MODEL }];
}
} catch { /* use default */ }
// Skip media-only providers in chat model selector
if (MEDIA_PROTOCOLS.has(protocol) || MEDIA_PROVIDER_TYPES.has(provider.provider_type)) continue;
// Get models: DB provider_models first, then catalog defaults, then env fallback
let rawModels: ModelEntry[];
// 1) Check DB provider_models table
let dbModels: { value: string; label: string; upstreamModelId?: string; capabilities?: Record<string, unknown> }[] = [];
try {
const provModels = getModelsForProvider(provider.id);
if (provModels.length > 0) {
dbModels = provModels.map(m => {
let caps: Record<string, unknown> | undefined;
let vars: Record<string, unknown> | undefined;
try { const p = JSON.parse(m.capabilities_json || '{}'); if (Object.keys(p).length > 0) caps = p; } catch { /* ignore */ }
try { const v = JSON.parse(m.variants_json || '{}'); if (Object.keys(v).length > 0) vars = v; } catch { /* ignore */ }
return {
value: m.model_id,
label: m.display_name || m.model_id,
upstreamModelId: m.upstream_model_id || undefined,
capabilities: caps,
variants: vars,
};
});
}
} catch { /* table may not exist in old DBs */ }
// 2) Catalog defaults
const catalogModels = getDefaultModelsForProvider(protocol, provider.base_url);
const catalogRaw = catalogModels.map(m => ({
value: m.modelId,
label: m.displayName,
upstreamModelId: m.upstreamModelId,
capabilities: m.capabilities as Record<string, unknown> | undefined,
}));
// Start with DB models + catalog defaults
if (dbModels.length > 0) {
const dbIds = new Set(dbModels.map(m => m.value));
rawModels = [...dbModels, ...catalogRaw.filter(m => !dbIds.has(m.value))];
} else if (catalogRaw.length > 0) {
rawModels = [...catalogRaw];
} else {
rawModels = [...DEFAULT_MODELS];
}
// Inject role_models_json.default into the list if not already present
// (e.g. user configured "ark-code-latest" for a Volcengine or anthropic-thirdparty provider)
try {
const rm = JSON.parse(provider.role_models_json || '{}');
if (rm.default && !rawModels.some(m => m.value === rm.default)) {
rawModels.unshift({ value: rm.default, label: rm.default });
}
} catch { /* ignore */ }
// Legacy: inject ANTHROPIC_MODEL from env overrides if not already present
try {
const envOverrides = provider.env_overrides_json || provider.extra_env || '{}';
const envObj = JSON.parse(envOverrides);
if (envObj.ANTHROPIC_MODEL && !rawModels.some(m => m.value === envObj.ANTHROPIC_MODEL)) {
rawModels.unshift({ value: envObj.ANTHROPIC_MODEL, label: envObj.ANTHROPIC_MODEL });
}
} catch { /* ignore */ }
const models = deduplicateModels(rawModels).map(m => {
const cw = getContextWindow(m.value);
return cw != null ? { ...m, contextWindow: cw } : m;
return {
...m,
...(cw != null ? { contextWindow: cw } : {}),
};
});
groups.push({
+13 -5
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { getSetting, getDefaultProviderId, getSession } from '@/lib/db';
import { getSetting, getSession } from '@/lib/db';
import { resolveProvider } from '@/lib/provider-resolver';
import { loadState, saveState, writeDailyMemory } from '@/lib/assistant-workspace';
import { getLocalDateString } from '@/lib/utils';
import { generateTextFromProvider } from '@/lib/text-generator';
@@ -42,9 +43,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
}
// Validate that the calling session belongs to this workspace
// Look up the calling session for workspace validation AND provider/model context
let session: ReturnType<typeof getSession> | undefined;
if (sessionId) {
const session = getSession(sessionId);
session = getSession(sessionId) ?? undefined;
if (session && session.working_directory !== workspacePath) {
return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 });
}
@@ -66,8 +68,14 @@ export async function POST(request: NextRequest) {
try { existingUser = fs.readFileSync(userPath, 'utf-8'); } catch { /* new file */ }
try {
const providerId = getDefaultProviderId() || '';
const model = getSetting('default_model') || 'claude-sonnet-4-20250514';
// Resolve using session's provider/model so check-in uses the same provider as chat
const resolved = resolveProvider({
sessionProviderId: session?.provider_id || undefined,
sessionModel: session?.model || undefined,
});
// Preserve 'env' semantics (see onboarding route for rationale)
const providerId = resolved.provider?.id || 'env';
const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514';
// Generate daily memory entry (episodic, not destructive)
const dailyMemoryPrompt = `You maintain daily memory entries for an AI assistant. Given the user's daily check-in answers, generate a daily memory entry for ${today}.
+14 -5
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { getSetting, getDefaultProviderId, getSession } from '@/lib/db';
import { getSetting, getSession } from '@/lib/db';
import { resolveProvider } from '@/lib/provider-resolver';
import { loadState, saveState, ensureDailyDir, generateRootDocs } from '@/lib/assistant-workspace';
import { getLocalDateString } from '@/lib/utils';
import { generateTextFromProvider } from '@/lib/text-generator';
@@ -62,9 +63,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
}
// Validate that the calling session belongs to this workspace
// Look up the calling session for workspace validation AND provider/model context
let session: ReturnType<typeof getSession> | undefined;
if (sessionId) {
const session = getSession(sessionId);
session = getSession(sessionId) ?? undefined;
if (session && session.working_directory !== workspacePath) {
return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 });
}
@@ -82,8 +84,15 @@ export async function POST(request: NextRequest) {
let memoryContent: string;
try {
const providerId = getDefaultProviderId() || '';
const model = getSetting('default_model') || 'claude-sonnet-4-20250514';
// Resolve using session's provider/model so onboarding uses the same provider as chat
const resolved = resolveProvider({
sessionProviderId: session?.provider_id || undefined,
sessionModel: session?.model || undefined,
});
// Preserve 'env' semantics: when provider=undefined, pass 'env' so text-generator
// uses shell env credentials instead of falling back to a random DB provider
const providerId = resolved.provider?.id || 'env';
const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514';
const soulPrompt = `Based on the following user onboarding answers, generate a concise "soul.md" file that defines an AI assistant's personality, communication style, and behavioral rules. Write in second person ("You are..."). Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;