feat: enable permission-aware subagents

This commit is contained in:
叶林立
2026-08-26 10:49:26 +08:00
parent d3bf562189
commit 7571ba6dd9
185 changed files with 48365 additions and 26 deletions
+100
View File
@@ -0,0 +1,100 @@
/**
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
*/
export interface ModelEntry {
id: string;
name: string;
provider: string;
}
export interface ModelRegistry {
find(provider: string, modelId: string): any;
getAll(): any[];
getAvailable?(): any[];
}
/**
* Resolve a model string to a Model instance.
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
* Returns the Model on success, or an error message string on failure.
*/
export function resolveModel(
input: string,
registry: ModelRegistry,
): any | string {
// Available models (those with auth configured)
const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
// 1. Exact match: "provider/modelId" — only if available (has auth)
const slashIdx = input.indexOf("/");
if (slashIdx !== -1) {
const provider = input.slice(0, slashIdx);
const modelId = input.slice(slashIdx + 1);
if (availableSet.has(input.toLowerCase())) {
const found = registry.find(provider, modelId);
if (found) return found;
}
}
// 2. Fuzzy match against available models. Normalize separators so cosmetic
// punctuation differences still match — e.g. "claude-haiku-4.5" and
// "claude-haiku-4-5" (dot vs dash in the version) resolve to the same model.
const normalize = (s: string) => s.toLowerCase().replace(/\./g, "-");
const query = normalize(input);
// Score each model: prefer exact id match > id contains > name contains > provider+id contains
let bestMatch: ModelEntry | undefined;
let bestScore = 0;
for (const m of all) {
const id = normalize(m.id);
const name = normalize(m.name);
const full = normalize(`${m.provider}/${m.id}`);
let score = 0;
if (id === query || full === query) {
score = 100; // exact
} else if (id.includes(query) || full.includes(query)) {
score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
} else if (name.includes(query)) {
score = 40 + (query.length / name.length) * 20;
} else if (
// A trailing date-stamp token (e.g. "20251001") is optional, so a
// date-pinned config like "claude-haiku-4-5-20251001" still matches an
// undated registry id like "claude-haiku-4-5".
query
.split(/[\s\-/]+/)
.every(part => /^\d{8}$/.test(part) || id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))
) {
score = 20; // all parts present somewhere
}
if (score > bestScore) {
bestScore = score;
bestMatch = m;
}
}
if (bestMatch && bestScore >= 20) {
const found = registry.find(bestMatch.provider, bestMatch.id);
if (found) return found;
}
// 3. Provider fallback: a "provider/modelId" query that didn't match under the
// named provider (exact or fuzzy above) retries against all providers. The
// named provider is preferred when present; this only kicks in when it isn't,
// so the same model from another provider beats falling back to "inherit".
if (slashIdx !== -1) {
const bare = resolveModel(input.slice(slashIdx + 1), registry);
if (typeof bare !== "string") return bare;
}
// 4. No match — list available models
const modelList = all
.map(m => ` ${m.provider}/${m.id}`)
.sort()
.join("\n");
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
}