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:
1 parent
4dc787c4a4
commit
f76e4747cd
21 files changed
+2932
-344
No files matched your search
+13
-21
@@ -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',
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}.
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
Reference in new issue
Block a user