|
|
@@ -142,6 +142,17 @@ interface EnvConfigStatus {
|
|
|
effectiveSource: "project" | "global" | null;
|
|
|
}
|
|
|
|
|
|
+interface ServiceProbeResult {
|
|
|
+ ok: boolean;
|
|
|
+ models: Array<{ id: string; name: string }>;
|
|
|
+ selectedModel?: string;
|
|
|
+ apiFormat?: "chat" | "responses";
|
|
|
+ stream?: boolean;
|
|
|
+ baseUrl?: string;
|
|
|
+ modelsSource?: "api" | "fallback";
|
|
|
+ error?: string;
|
|
|
+}
|
|
|
+
|
|
|
function broadcast(event: string, data: unknown): void {
|
|
|
for (const handler of subscribers) {
|
|
|
handler(event, data);
|
|
|
@@ -311,6 +322,167 @@ async function resolveConfiguredServiceEntry(root: string, serviceId: string): P
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function buildProbePlans(
|
|
|
+ preferredApiFormat: "chat" | "responses" | undefined,
|
|
|
+ preferredStream: boolean | undefined,
|
|
|
+): Array<{ apiFormat: "chat" | "responses"; stream: boolean }> {
|
|
|
+ const candidates: Array<{ apiFormat: "chat" | "responses"; stream: boolean }> = [];
|
|
|
+ const seen = new Set<string>();
|
|
|
+ const push = (apiFormat: "chat" | "responses", stream: boolean) => {
|
|
|
+ const key = `${apiFormat}:${stream ? "1" : "0"}`;
|
|
|
+ if (seen.has(key)) return;
|
|
|
+ seen.add(key);
|
|
|
+ candidates.push({ apiFormat, stream });
|
|
|
+ };
|
|
|
+
|
|
|
+ if (preferredApiFormat) {
|
|
|
+ push(preferredApiFormat, preferredStream ?? false);
|
|
|
+ push(preferredApiFormat, !(preferredStream ?? false));
|
|
|
+ }
|
|
|
+ const alternate = preferredApiFormat === "responses" ? "chat" : "responses";
|
|
|
+ push(alternate, false);
|
|
|
+ push(alternate, true);
|
|
|
+ push("chat", false);
|
|
|
+ push("chat", true);
|
|
|
+ push("responses", false);
|
|
|
+ push("responses", true);
|
|
|
+ return candidates;
|
|
|
+}
|
|
|
+
|
|
|
+function buildModelCandidates(args: {
|
|
|
+ preferredModel?: string;
|
|
|
+ configModel?: string;
|
|
|
+ envModel?: string | null;
|
|
|
+ discoveredModels: Array<{ id: string; name: string }>;
|
|
|
+}): string[] {
|
|
|
+ const seen = new Set<string>();
|
|
|
+ const candidates: string[] = [];
|
|
|
+ const push = (value: string | null | undefined) => {
|
|
|
+ if (!value || value.trim().length === 0) return;
|
|
|
+ const id = value.trim();
|
|
|
+ if (seen.has(id)) return;
|
|
|
+ seen.add(id);
|
|
|
+ candidates.push(id);
|
|
|
+ };
|
|
|
+
|
|
|
+ push(args.preferredModel);
|
|
|
+ push(args.configModel);
|
|
|
+ push(args.envModel ?? undefined);
|
|
|
+ for (const model of args.discoveredModels) push(model.id);
|
|
|
+ push("gpt-5.4");
|
|
|
+ push("gpt-4o");
|
|
|
+ push("claude-sonnet-4-6");
|
|
|
+ push("MiniMax-M2.7");
|
|
|
+ push("kimi-k2.5");
|
|
|
+ return candidates;
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchModelsFromServiceBaseUrl(
|
|
|
+ baseUrl: string,
|
|
|
+ apiKey: string,
|
|
|
+): Promise<{ models: Array<{ id: string; name: string }>; error?: string }> {
|
|
|
+ const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
|
|
|
+ try {
|
|
|
+ const res = await fetch(modelsUrl, {
|
|
|
+ headers: { Authorization: `Bearer ${apiKey}` },
|
|
|
+ signal: AbortSignal.timeout(10_000),
|
|
|
+ });
|
|
|
+ if (!res.ok) {
|
|
|
+ const body = await res.text().catch(() => "");
|
|
|
+ return { models: [], error: `服务商返回 ${res.status}: ${body.slice(0, 200)}` };
|
|
|
+ }
|
|
|
+ const json = await res.json() as { data?: Array<{ id: string }> };
|
|
|
+ return {
|
|
|
+ models: (json.data ?? []).map((m) => ({ id: m.id, name: m.id })),
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ return {
|
|
|
+ models: [],
|
|
|
+ error: error instanceof Error ? error.message : String(error),
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function probeServiceCapabilities(args: {
|
|
|
+ root: string;
|
|
|
+ service: string;
|
|
|
+ apiKey: string;
|
|
|
+ baseUrl: string;
|
|
|
+ preferredApiFormat?: "chat" | "responses";
|
|
|
+ preferredStream?: boolean;
|
|
|
+ preferredModel?: string;
|
|
|
+}): Promise<ServiceProbeResult> {
|
|
|
+ const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record<string, unknown>));
|
|
|
+ const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
|
|
|
+ const envConfig = await readEnvConfigStatus(args.root);
|
|
|
+ const envModel = envConfig.effectiveSource === "project"
|
|
|
+ ? envConfig.project.model
|
|
|
+ : envConfig.effectiveSource === "global"
|
|
|
+ ? envConfig.global.model
|
|
|
+ : null;
|
|
|
+
|
|
|
+ const modelsResponse = await fetchModelsFromServiceBaseUrl(args.baseUrl, args.apiKey);
|
|
|
+ const discoveredModels = modelsResponse.models;
|
|
|
+ const modelCandidates = buildModelCandidates({
|
|
|
+ preferredModel: args.preferredModel,
|
|
|
+ configModel: typeof llm.defaultModel === "string" ? llm.defaultModel : typeof llm.model === "string" ? llm.model : undefined,
|
|
|
+ envModel,
|
|
|
+ discoveredModels,
|
|
|
+ });
|
|
|
+
|
|
|
+ if (modelCandidates.length === 0) {
|
|
|
+ return {
|
|
|
+ ok: false,
|
|
|
+ models: [],
|
|
|
+ error: "无法自动确定模型,请先填写可用模型或提供支持 /models 的服务端点。",
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ let lastError = modelsResponse.error ?? "自动探测失败";
|
|
|
+
|
|
|
+ for (const model of modelCandidates) {
|
|
|
+ for (const plan of buildProbePlans(args.preferredApiFormat, args.preferredStream)) {
|
|
|
+ const client = createLLMClient({
|
|
|
+ provider: args.service === "anthropic" ? "anthropic" : "openai",
|
|
|
+ service: isCustomServiceId(args.service) ? "custom" : args.service,
|
|
|
+ configSource: "studio",
|
|
|
+ baseUrl: args.baseUrl,
|
|
|
+ apiKey: args.apiKey.trim(),
|
|
|
+ model,
|
|
|
+ temperature: 0.7,
|
|
|
+ maxTokens: 64,
|
|
|
+ thinkingBudget: 0,
|
|
|
+ apiFormat: plan.apiFormat,
|
|
|
+ stream: plan.stream,
|
|
|
+ } as ProjectConfig["llm"]);
|
|
|
+
|
|
|
+ try {
|
|
|
+ await chatCompletion(client, model, [{ role: "user", content: "ping" }], { maxTokens: 5 });
|
|
|
+ const models = discoveredModels.length > 0
|
|
|
+ ? discoveredModels
|
|
|
+ : [{ id: model, name: model }];
|
|
|
+ return {
|
|
|
+ ok: true,
|
|
|
+ models,
|
|
|
+ selectedModel: model,
|
|
|
+ apiFormat: plan.apiFormat,
|
|
|
+ stream: plan.stream,
|
|
|
+ baseUrl: args.baseUrl,
|
|
|
+ modelsSource: discoveredModels.length > 0 ? "api" : "fallback",
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ lastError = error instanceof Error ? error.message : String(error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ ok: false,
|
|
|
+ models: discoveredModels,
|
|
|
+ error: lastError,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
// --- Server factory ---
|
|
|
|
|
|
export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
@@ -753,7 +925,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
});
|
|
|
|
|
|
app.put("/api/v1/services/config", async (c) => {
|
|
|
- const body = await c.req.json<{ services?: unknown; defaultModel?: string; configSource?: LLMConfigSource }>();
|
|
|
+ const body = await c.req.json<{ services?: unknown; defaultModel?: string; configSource?: LLMConfigSource; service?: string }>();
|
|
|
const config = await loadRawConfig(root);
|
|
|
config.llm = config.llm ?? {};
|
|
|
const llm = config.llm as Record<string, unknown>;
|
|
|
@@ -768,6 +940,9 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
if (body.configSource !== undefined) {
|
|
|
llm.configSource = normalizeConfigSource(body.configSource);
|
|
|
}
|
|
|
+ if (body.service !== undefined) {
|
|
|
+ llm.service = body.service;
|
|
|
+ }
|
|
|
await saveRawConfig(root, config);
|
|
|
return c.json({ ok: true });
|
|
|
});
|
|
|
@@ -790,71 +965,31 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
|
|
|
}
|
|
|
|
|
|
- // Call the real /models API — no fallback
|
|
|
- const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
|
|
|
- try {
|
|
|
- const res = await fetch(modelsUrl, {
|
|
|
- headers: { Authorization: `Bearer ${apiKey.trim()}` },
|
|
|
- signal: AbortSignal.timeout(10_000),
|
|
|
- });
|
|
|
-
|
|
|
- if (!res.ok) {
|
|
|
- const body = await res.text().catch(() => "");
|
|
|
- if (res.status === 401 || res.status === 403) {
|
|
|
- return c.json({ ok: false, error: "API Key 无效,请检查后重试" }, 400);
|
|
|
- }
|
|
|
- return c.json({ ok: false, error: `服务商返回 ${res.status}: ${body.slice(0, 200)}` }, 400);
|
|
|
- }
|
|
|
-
|
|
|
- const json = await res.json() as { data?: Array<{ id: string; owned_by?: string }> };
|
|
|
- const models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
|
|
|
-
|
|
|
- if (models.length === 0) {
|
|
|
- return c.json({ ok: false, error: "连接成功但未返回可用模型" }, 400);
|
|
|
- }
|
|
|
-
|
|
|
- const rawConfig = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
|
|
|
- const preferredModel = typeof (rawConfig.llm as Record<string, unknown> | undefined)?.defaultModel === "string"
|
|
|
- ? String((rawConfig.llm as Record<string, unknown>).defaultModel)
|
|
|
- : undefined;
|
|
|
- const sampleModel = models.find((m) => m.id === preferredModel)?.id
|
|
|
- ?? models.find((m) => m.id === "gpt-5.4")?.id
|
|
|
- ?? models[0]?.id;
|
|
|
- if (sampleModel) {
|
|
|
- const client = createLLMClient({
|
|
|
- provider: service === "anthropic" ? "anthropic" : "openai",
|
|
|
- service: isCustomServiceId(service) ? "custom" : service,
|
|
|
- configSource: "studio",
|
|
|
- baseUrl: resolvedBaseUrl,
|
|
|
- apiKey: apiKey.trim(),
|
|
|
- model: sampleModel,
|
|
|
- temperature: 0.7,
|
|
|
- maxTokens: 64,
|
|
|
- thinkingBudget: 0,
|
|
|
- apiFormat: apiFormat ?? "chat",
|
|
|
- stream: stream ?? true,
|
|
|
- } as ProjectConfig["llm"]);
|
|
|
-
|
|
|
- try {
|
|
|
- await chatCompletion(
|
|
|
- client,
|
|
|
- sampleModel,
|
|
|
- [{ role: "user", content: "ping" }],
|
|
|
- { maxTokens: 5 },
|
|
|
- );
|
|
|
- } catch (error) {
|
|
|
- const message = error instanceof Error ? error.message : String(error);
|
|
|
- return c.json({ ok: false, error: `模型列表可读,但生成测试失败:${message}` }, 400);
|
|
|
- }
|
|
|
- }
|
|
|
+ const probe = await probeServiceCapabilities({
|
|
|
+ root,
|
|
|
+ service,
|
|
|
+ apiKey: apiKey.trim(),
|
|
|
+ baseUrl: resolvedBaseUrl,
|
|
|
+ preferredApiFormat: apiFormat,
|
|
|
+ preferredStream: stream,
|
|
|
+ });
|
|
|
|
|
|
- return c.json({ ok: true, modelCount: models.length, models: models.slice(0, 50) });
|
|
|
- } catch (err: any) {
|
|
|
- if (err?.name === "TimeoutError" || err?.name === "AbortError") {
|
|
|
- return c.json({ ok: false, error: `连接超时:无法访问 ${modelsUrl}` }, 400);
|
|
|
- }
|
|
|
- return c.json({ ok: false, error: `连接失败: ${err?.message ?? String(err)}` }, 400);
|
|
|
+ if (!probe.ok) {
|
|
|
+ return c.json({ ok: false, error: probe.error ?? "连接失败" }, 400);
|
|
|
}
|
|
|
+
|
|
|
+ return c.json({
|
|
|
+ ok: true,
|
|
|
+ modelCount: probe.models.length,
|
|
|
+ models: probe.models.slice(0, 50),
|
|
|
+ selectedModel: probe.selectedModel,
|
|
|
+ detected: {
|
|
|
+ apiFormat: probe.apiFormat,
|
|
|
+ stream: probe.stream,
|
|
|
+ baseUrl: probe.baseUrl,
|
|
|
+ modelsSource: probe.modelsSource,
|
|
|
+ },
|
|
|
+ });
|
|
|
});
|
|
|
|
|
|
app.put("/api/v1/services/:service/secret", async (c) => {
|
|
|
@@ -888,20 +1023,22 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
|
|
|
if (!resolvedBaseUrl) return c.json({ models: [] });
|
|
|
|
|
|
- // Call real API only
|
|
|
- let models: Array<{ id: string; name: string }> = [];
|
|
|
- try {
|
|
|
- const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
|
|
|
- const res = await fetch(modelsUrl, {
|
|
|
- headers: { Authorization: `Bearer ${apiKey}` },
|
|
|
- signal: AbortSignal.timeout(10_000),
|
|
|
- });
|
|
|
- if (res.ok) {
|
|
|
- const json = await res.json() as { data?: Array<{ id: string }> };
|
|
|
- models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
|
|
|
- }
|
|
|
- } catch { /* timeout or network error — return empty */ }
|
|
|
- return c.json({ models });
|
|
|
+ const rawConfig = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
|
|
|
+ const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
|
|
|
+ const preferredModel = typeof llm.defaultModel === "string" ? llm.defaultModel : undefined;
|
|
|
+ const serviceEntry = await resolveConfiguredServiceEntry(root, service);
|
|
|
+ const probe = await probeServiceCapabilities({
|
|
|
+ root,
|
|
|
+ service,
|
|
|
+ apiKey,
|
|
|
+ baseUrl: resolvedBaseUrl,
|
|
|
+ preferredApiFormat: serviceEntry?.apiFormat,
|
|
|
+ preferredStream: serviceEntry?.stream,
|
|
|
+ preferredModel,
|
|
|
+ });
|
|
|
+ return c.json({
|
|
|
+ models: probe.ok ? probe.models : [],
|
|
|
+ });
|
|
|
});
|
|
|
|
|
|
// --- Project info ---
|
|
|
@@ -2124,10 +2261,17 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
|
|
|
try {
|
|
|
const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
|
|
|
- const client = createLLMClient(currentConfig.llm);
|
|
|
- const { chatCompletion } = await import("@actalk/inkos-core");
|
|
|
- await chatCompletion(client, currentConfig.llm.model, [{ role: "user", content: "ping" }], { maxTokens: 5 });
|
|
|
- checks.llmConnected = true;
|
|
|
+ const service = currentConfig.llm.service ?? currentConfig.llm.provider;
|
|
|
+ const probe = await probeServiceCapabilities({
|
|
|
+ root,
|
|
|
+ service,
|
|
|
+ apiKey: currentConfig.llm.apiKey,
|
|
|
+ baseUrl: currentConfig.llm.baseUrl,
|
|
|
+ preferredApiFormat: currentConfig.llm.apiFormat,
|
|
|
+ preferredStream: currentConfig.llm.stream,
|
|
|
+ preferredModel: currentConfig.llm.model,
|
|
|
+ });
|
|
|
+ checks.llmConnected = probe.ok;
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
|
return c.json(checks);
|