- Cowork: ComboFormModal

- BaseUrlSelect: add cloud endpoint option, custom URL local state, always
  default to first option; new cliEndpointMatch helper; CLI tool cards refactor
- API: new /v1/audio/voices and /v1/models/info; /v1/models filters disabled
  models, drop unused timestamp
- initializeApp: guard tunnel/tailscale auto-resume to once-per-process
- geminiHelper: ensureObjectType for schemas with properties but no type
- skills: minor SKILL.md tweaks (chat/embeddings/image/stt/tts/web-*)
This commit is contained in:
decolua
2026-05-07 15:45:09 +07:00
parent 6344abcf8d
commit 5c62e73cc6
28 changed files with 1897 additions and 320 deletions

View File

@@ -0,0 +1,68 @@
import { AI_PROVIDERS } from "@/shared/constants/providers";
// Provider → internal voices API. Edge/local-device share the generic endpoint.
const PROVIDER_API = {
elevenlabs: (origin) => `${origin}/api/media-providers/tts/elevenlabs/voices`,
deepgram: (origin) => `${origin}/api/media-providers/tts/deepgram/voices`,
inworld: (origin) => `${origin}/api/media-providers/tts/inworld/voices`,
"edge-tts": (origin) => `${origin}/api/media-providers/tts/voices?provider=edge-tts`,
"local-device": (origin) => `${origin}/api/media-providers/tts/voices?provider=local-device`,
};
export async function OPTIONS() {
return new Response(null, {
headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" },
});
}
// GET /v1/audio/voices?provider={p}[&lang=xx]
// Returns OpenAI-style list with each voice's full model id ready for /v1/audio/speech
export async function GET(request) {
try {
const { searchParams, origin } = new URL(request.url);
const provider = searchParams.get("provider");
const lang = searchParams.get("lang");
if (!provider || !PROVIDER_API[provider]) {
return Response.json(
{ error: { message: `provider must be one of: ${Object.keys(PROVIDER_API).join(", ")}`, type: "invalid_request_error" } },
{ status: 400, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
const baseUrl = PROVIDER_API[provider](origin);
const url = lang ? `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}lang=${encodeURIComponent(lang)}` : baseUrl;
const res = await fetch(url, { cache: "no-store" });
const data = await res.json();
if (!res.ok || data.error) {
return Response.json(
{ error: { message: data.error || `Upstream ${res.status}`, type: "server_error" } },
{ status: res.status, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
// Internal API shape: { voices } when lang filter, else { byLang, languages }
const rawVoices = lang
? (data.voices || [])
: Object.values(data.byLang || {}).flatMap((l) => l.voices || []);
// Use provider alias for /v1/audio/speech model param (matches skill convention e.g. el/, dg/, edge-tts/)
const alias = AI_PROVIDERS[provider]?.alias || provider;
const data_out = rawVoices.map((v) => ({
id: v.id,
name: v.name,
lang: v.lang || "",
gender: v.gender || "",
model: `${alias}/${v.id}`,
}));
return Response.json({ object: "list", data: data_out }, {
headers: { "Access-Control-Allow-Origin": "*" },
});
} catch (err) {
return Response.json(
{ error: { message: err.message || "Failed", type: "server_error" } },
{ status: 502, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
}

View File

@@ -0,0 +1,110 @@
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
const KIND_ENDPOINT = {
llm: "/v1/chat/completions",
image: "/v1/images/generations",
tts: "/v1/audio/speech",
stt: "/v1/audio/transcriptions",
embedding: "/v1/embeddings",
imageToText: "/v1/chat/completions",
webSearch: "/v1/search",
webFetch: "/v1/fetch",
};
const TTS_VOICES_API = new Set(["elevenlabs", "edge-tts", "deepgram", "inworld", "local-device"]);
function buildInfo({ alias, providerId, model, kind, providerInfo }) {
const out = {
id: `${alias}/${model.id}`,
name: model.name || model.id,
kind,
owned_by: alias,
endpoint: KIND_ENDPOINT[kind] || null,
};
if (model.params) out.params = model.params;
if (model.capabilities) out.capabilities = model.capabilities;
if (model.options) out.options = model.options;
if (model.dimensions) out.dimensions = model.dimensions;
if (model.contextWindow) out.contextWindow = model.contextWindow;
if (kind === "tts" && TTS_VOICES_API.has(providerId)) {
out.voicesUrl = `/v1/audio/voices?provider=${providerId}`;
}
if (kind === "webSearch" && providerInfo?.searchConfig) {
const cfg = providerInfo.searchConfig;
if (cfg.searchTypes) out.searchTypes = cfg.searchTypes;
if (cfg.maxMaxResults) out.maxResults = cfg.maxMaxResults;
if (cfg.requiredOptions) out.required = cfg.requiredOptions;
}
return out;
}
// id format: "{alias}/{modelId}" - alias may also be providerId
function lookup(fullId) {
if (!fullId || !fullId.includes("/")) return null;
const slash = fullId.indexOf("/");
const alias = fullId.slice(0, slash);
const modelId = fullId.slice(slash + 1);
const providerId = ALIAS_TO_ID[alias] || alias;
const providerInfo = AI_PROVIDERS[providerId];
// PROVIDER_MODELS lookup (by alias key, fallback to providerId)
const list = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || [];
const m = list.find((x) => x.id === modelId);
if (m) {
const kind = m.type || "llm";
return buildInfo({ alias, providerId, model: m, kind, providerInfo });
}
// Sub-configs (TTS/STT/embedding only-in-config)
const subs = [
["tts", providerInfo?.ttsConfig],
["stt", providerInfo?.sttConfig],
["embedding", providerInfo?.embeddingConfig],
];
for (const [kind, cfg] of subs) {
const sm = cfg?.models?.find((x) => x.id === modelId);
if (sm) return buildInfo({ alias, providerId, model: sm, kind, providerInfo });
}
// Web search/fetch — virtual model id "search" / "fetch"
if (modelId === "search" && providerInfo?.searchConfig) {
return buildInfo({
alias, providerId, kind: "webSearch", providerInfo,
model: { id: "search", name: `${providerInfo.name} Search`, params: ["query", "max_results", "country", "language", "time_range", "domain_filter", "search_type"] },
});
}
if (modelId === "fetch" && providerInfo?.fetchConfig) {
return buildInfo({
alias, providerId, kind: "webFetch", providerInfo,
model: { id: "fetch", name: `${providerInfo.name} Fetch`, params: ["url", "format", "max_characters"] },
});
}
return null;
}
export async function OPTIONS() {
return new Response(null, {
headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" },
});
}
// GET /v1/models/info?id={alias}/{modelId} — metadata for a single model
export async function GET(request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return Response.json(
{ error: { message: "Missing required query param: id (e.g. ?id=openai/dall-e-3)", type: "invalid_request_error" } },
{ status: 400, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
const info = lookup(id);
if (!info) {
return Response.json(
{ error: { message: `Model not found: ${id}`, type: "not_found" } },
{ status: 404, headers: { "Access-Control-Allow-Origin": "*" } },
);
}
return Response.json(info, { headers: { "Access-Control-Allow-Origin": "*" } });
}

View File

@@ -6,6 +6,7 @@ import {
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
import { getDisabledModels } from "@/lib/disabledModelsDb";
const parseOpenAIStyleModels = (data) => {
if (Array.isArray(data)) return data;
@@ -151,6 +152,14 @@ export async function buildModelsList(kindFilter) {
console.log("Could not fetch model aliases");
}
let disabledByAlias = {};
try {
disabledByAlias = await getDisabledModels();
} catch (e) {
console.log("Could not fetch disabled models");
}
const isDisabled = (alias, modelId) => Array.isArray(disabledByAlias[alias]) && disabledByAlias[alias].includes(modelId);
const activeConnectionByProvider = new Map();
for (const conn of connections) {
if (!activeConnectionByProvider.has(conn.provider)) {
@@ -159,7 +168,6 @@ export async function buildModelsList(kindFilter) {
}
const models = [];
const timestamp = Math.floor(Date.now() / 1000);
// Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch.
for (const combo of combos) {
@@ -167,7 +175,6 @@ export async function buildModelsList(kindFilter) {
const entry = {
id: combo.name,
object: "model",
created: timestamp,
owned_by: "combo",
};
if (combo.kind === "webSearch" || combo.kind === "webFetch") {
@@ -186,10 +193,10 @@ export async function buildModelsList(kindFilter) {
if (!providerMatchesKinds(providerId, kindFilter)) continue;
for (const model of providerModels) {
if (!kindFilter.includes(modelKind(model))) continue;
if (isDisabled(alias, model.id)) continue;
models.push({
id: `${alias}/${model.id}`,
object: "model",
created: timestamp,
owned_by: alias,
});
}
@@ -208,7 +215,6 @@ export async function buildModelsList(kindFilter) {
models.push({
id: `${providerAlias}/${modelId}`,
object: "model",
created: timestamp,
owned_by: providerAlias,
});
}
@@ -301,11 +307,11 @@ export async function buildModelsList(kindFilter) {
// Resolve kind: prefer static metadata, otherwise infer from ID heuristics
const kind = staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId);
if (!kindFilter.includes(kind)) continue;
if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue;
models.push({
id: `${outputAlias}/${modelId}`,
object: "model",
created: timestamp,
owned_by: outputAlias,
});
}
@@ -324,10 +330,10 @@ export async function buildModelsList(kindFilter) {
}
}
for (const subId of subConfigModels) {
if (isDisabled(outputAlias, subId) || isDisabled(staticAlias, subId)) continue;
models.push({
id: `${outputAlias}/${subId}`,
object: "model",
created: timestamp,
owned_by: outputAlias,
});
}
@@ -338,7 +344,6 @@ export async function buildModelsList(kindFilter) {
id: `${outputAlias}/search`,
object: "model",
kind: "webSearch",
created: timestamp,
owned_by: outputAlias,
});
}
@@ -347,7 +352,6 @@ export async function buildModelsList(kindFilter) {
id: `${outputAlias}/fetch`,
object: "model",
kind: "webFetch",
created: timestamp,
owned_by: outputAlias,
});
}