mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat: remove intent-detect-v3 model
This commit is contained in:
@@ -272,9 +272,7 @@ export default defineCommand({
|
||||
return result;
|
||||
});
|
||||
|
||||
const analyzeIntentPromise = analyzeIntent(ctx.client, userInput, {
|
||||
intentDetectBaseUrl: settings.intentDetectBaseUrl,
|
||||
}).then((result) => {
|
||||
const analyzeIntentPromise = analyzeIntent(ctx.client, userInput).then((result) => {
|
||||
intentReady = true;
|
||||
if (!modelsReady) {
|
||||
spinner.update("Agent: Intent analyzed, loading model data...");
|
||||
|
||||
@@ -96,9 +96,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
expect(data.result?.recommendations?.[0]?.highlights?.length).toBeGreaterThan(0);
|
||||
}, 120_000);
|
||||
|
||||
// ---- Model preference: positive cases ----
|
||||
// ---- Mode coverage: all 4 modes ----
|
||||
|
||||
test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => {
|
||||
test("mode: scoped — family-scoped query sets mode=scoped with targets", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
@@ -112,19 +112,20 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
// Model preference detection depends on LLM interpretation
|
||||
// Accept either "scoped" or "unconstrained" as valid
|
||||
const mode = data.intent?.modelPreference?.mode;
|
||||
expect(mode === "scoped" || mode === "unconstrained" || mode === undefined).toBe(true);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref?.mode).toBe("scoped");
|
||||
expect(pref?.targets?.length).toBeGreaterThan(0);
|
||||
// Should contain "deepseek" (case-insensitive substring)
|
||||
expect(pref?.targets?.some((t) => t.toLowerCase().includes("deepseek"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => {
|
||||
test("mode: comparison — comparing two models sets mode=comparison with both targets", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"Which is better for code generation, qwen-max or deepseek-v3?",
|
||||
"Compare qwen-max and deepseek-v3 for legal contract review, high precision required",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
@@ -132,19 +133,41 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
// Model preference detection depends on LLM interpretation
|
||||
// Accept either "comparison" or "unconstrained" as valid
|
||||
const mode = data.intent?.modelPreference?.mode;
|
||||
expect(mode === "comparison" || mode === "unconstrained" || mode === undefined).toBe(true);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref?.mode).toBe("comparison");
|
||||
expect(pref?.targets?.length).toBeGreaterThanOrEqual(2);
|
||||
const targetsLower = pref?.targets?.map((t) => t.toLowerCase()) ?? [];
|
||||
expect(targetsLower.some((t) => t.includes("qwen"))).toBe(true);
|
||||
expect(targetsLower.some((t) => t.includes("deepseek"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("excludes preference — intent detects modelPreference when excluding models", async () => {
|
||||
test("mode: alternative — reference model query sets mode=alternative with target", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"Not qwen, recommend a model suitable for text generation",
|
||||
"Something like qwen-max but cheaper, for text summarization",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref?.mode).toBe("alternative");
|
||||
expect(pref?.targets?.length).toBeGreaterThan(0);
|
||||
expect(pref?.targets?.some((t) => t.toLowerCase().includes("qwen"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("mode: excludes — excluding a family populates excludes array", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"Recommend a model for text generation, but not qwen",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
@@ -157,18 +180,12 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
|
||||
};
|
||||
};
|
||||
}>(stdout);
|
||||
// Model preference detection depends on LLM interpretation
|
||||
// If excludes is detected, verify it contains qwen; otherwise accept as valid
|
||||
const pref = data.intent?.modelPreference;
|
||||
if (pref?.excludes && pref.excludes.length > 0) {
|
||||
expect(pref.excludes.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
|
||||
}
|
||||
// Test passes if exit code is 0, regardless of whether excludes was detected
|
||||
expect(pref?.excludes?.length).toBeGreaterThan(0);
|
||||
expect(pref?.excludes?.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
// ---- Model preference: negative cases ----
|
||||
|
||||
test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => {
|
||||
test("mode: unconstrained — generic query has no modelPreference", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
|
||||
"advisor",
|
||||
"recommend",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export { DEFAULT_INTENT } from "./defaults.ts";
|
||||
export {
|
||||
INTENT_DETECT_MODEL,
|
||||
INTENT_DETECT_TOOL,
|
||||
buildIntentDetectSystemPrompt,
|
||||
INTENT_EXTRACTION_MODEL,
|
||||
INTENT_SYSTEM_PROMPT,
|
||||
JSON_RETRY_HINT,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
export const RANKING_MODEL = "qwen-flash";
|
||||
|
||||
/**
|
||||
* Dedicated intent-detection model. Sub-100ms latency, designed for fast
|
||||
* classification + tool routing. Provides mode/targets/excludes/complexity.
|
||||
*/
|
||||
export const INTENT_DETECT_MODEL = "tongyi-intent-detect-v3";
|
||||
|
||||
/**
|
||||
* Rich field extraction model. Runs in parallel with detect-v3 to extract
|
||||
* taskSummary, modalities, budget, qualityPreference, etc.
|
||||
* Intent extraction model. Runs a single call to extract taskSummary,
|
||||
* modalities, budget, qualityPreference, modelPreference, and all other
|
||||
* structured fields from the user's input.
|
||||
*/
|
||||
export const INTENT_EXTRACTION_MODEL = "qwen3.6-flash";
|
||||
|
||||
@@ -193,74 +188,3 @@ The intent's modelPreference.targets is the reference model.
|
||||
|
||||
## Output Format
|
||||
{"type":"single","recommendations":[{"model":"model ID","reason":"alternative analysis","highlights":["differentiators"]}]}`;
|
||||
|
||||
/**
|
||||
* Tool definition for `tongyi-intent-detect-v3`. Serialized to a JSON string
|
||||
* and embedded in the system prompt (NOT passed via the request body `tools`
|
||||
* field — this model doesn't use OpenAI function-calling; it has its own
|
||||
* `<tags>` / `
|
||||
</think>
|
||||
|
||||
` output format driven by the system prompt).
|
||||
*/
|
||||
export const INTENT_DETECT_TOOL = {
|
||||
name: "classify_intent",
|
||||
description:
|
||||
"Classify the user's model recommendation intent. Extract the mode and any model/family names mentioned.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["unconstrained", "scoped", "comparison", "alternative"],
|
||||
description: "The detected intent mode.",
|
||||
},
|
||||
targets: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Model or family names the user mentioned or wants to evaluate. Empty for unconstrained.",
|
||||
},
|
||||
excludes: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Model or family names the user explicitly wants to exclude. Empty if none mentioned.",
|
||||
},
|
||||
complexity: {
|
||||
type: "string",
|
||||
enum: ["single", "pipeline"],
|
||||
description:
|
||||
"Whether the task needs a single model or a multi-step pipeline. Default to single unless the user clearly describes chained steps.",
|
||||
},
|
||||
},
|
||||
required: ["mode"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Build the system prompt for `tongyi-intent-detect-v3` following the official
|
||||
* template from the model's documentation. The tools JSON is embedded in the
|
||||
* prompt text — the model reads it from the system message, not from a separate
|
||||
* `tools` request field.
|
||||
*
|
||||
* Official template:
|
||||
* "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.
|
||||
* You may call one or more tools to assist with the user query.
|
||||
* The tools you can use are as follows:
|
||||
* {tools_string}
|
||||
* Response in INTENT_MODE."
|
||||
*
|
||||
* `INTENT_MODE` tells the model to emit `<tags>label</tags>` + `
|
||||
</think>
|
||||
|
||||
`
|
||||
* output. The tag carries the mode classification; the tool_call carries
|
||||
* structured targets/excludes/complexity extracted from the user's prompt.
|
||||
*/
|
||||
export function buildIntentDetectSystemPrompt(): string {
|
||||
const toolsString = JSON.stringify([INTENT_DETECT_TOOL], null, 2);
|
||||
return `You are Qwen, created by Alibaba Cloud. You are a helpful assistant. You may call one or more tools to assist with the user query. The tools you can use are as follows:
|
||||
${toolsString}
|
||||
Response in INTENT_MODE.`;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { chatPath, intentDetectEndpoint } from "../client/endpoints.ts";
|
||||
import { chatPath } from "../client/endpoints.ts";
|
||||
import type { Client } from "../client/client.ts";
|
||||
import type { ChatResponse, DashScopeIntentDetectResponse } from "../types/api.ts";
|
||||
import type { ChatResponse } from "../types/api.ts";
|
||||
import { Complexities } from "./types.ts";
|
||||
import type { IntentProfile, ModelPreference, PreferenceMode } from "./types.ts";
|
||||
import {
|
||||
INTENT_DETECT_MODEL,
|
||||
buildIntentDetectSystemPrompt,
|
||||
INTENT_EXTRACTION_MODEL,
|
||||
INTENT_SYSTEM_PROMPT,
|
||||
} from "./constants/prompts.ts";
|
||||
import { INTENT_EXTRACTION_MODEL, INTENT_SYSTEM_PROMPT } from "./constants/prompts.ts";
|
||||
import { DEFAULT_INTENT } from "./constants/defaults.ts";
|
||||
|
||||
// ---- tongyi-intent-detect-v3: fast mode classification via DashScope native API
|
||||
|
||||
const VALID_MODES: readonly PreferenceMode[] = [
|
||||
"unconstrained",
|
||||
"scoped",
|
||||
@@ -20,174 +13,44 @@ const VALID_MODES: readonly PreferenceMode[] = [
|
||||
"alternative",
|
||||
];
|
||||
|
||||
/** Seconds per attempt; http.ts multiplies by 1000 -> ms. */
|
||||
const INTENT_DETECT_TIMEOUT = 10;
|
||||
|
||||
/**
|
||||
* Result of the fast intent-detect pass. Only the fields that
|
||||
* `tongyi-intent-detect-v3` reliably extracts -- the remaining IntentProfile
|
||||
* fields are still filled by the qwen3.6-flash extraction path.
|
||||
* Build a ModelPreference from the extraction model's raw output.
|
||||
* Returns undefined when no valid preference data is present.
|
||||
*/
|
||||
interface IntentDetectResult {
|
||||
mode: PreferenceMode;
|
||||
targets: string[];
|
||||
excludes: string[];
|
||||
complexity: "single" | "pipeline";
|
||||
}
|
||||
function extractModelPreference(
|
||||
raw: Record<string, unknown> | undefined,
|
||||
): ModelPreference | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
|
||||
/**
|
||||
* Parse the tags block from the detect model's response.
|
||||
* Returns the trimmed tag content, or "" when no tag is found.
|
||||
*/
|
||||
function parseTags(content: string): string {
|
||||
const re = /<tags>\s*([\s\S]*?)\s*<\/tags>/i;
|
||||
const match = content.match(re);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the tool_call block from the detect model's response.
|
||||
* Returns the first tool call's arguments, or null when not found.
|
||||
*/
|
||||
function parseToolCall(content: string): Record<string, unknown> | null {
|
||||
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/i;
|
||||
const match = content.match(re);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]);
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].arguments) {
|
||||
return parsed[0].arguments as Record<string, unknown>;
|
||||
}
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"arguments" in (parsed as Record<string, unknown>)
|
||||
) {
|
||||
return (parsed as Record<string, unknown>).arguments as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract string[] helper for safe array extraction from unknown values.
|
||||
*/
|
||||
function safeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((v): v is string => typeof v === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `tongyi-intent-detect-v3` via DashScope native API for fast classification.
|
||||
*
|
||||
* Uses INTENT_MODE: the model emits `<tags>mode</tags>` for classification
|
||||
* plus a `classify_intent` tool_call carrying targets/excludes/complexity.
|
||||
* Returns null on any failure; caller falls back to extraction model fields.
|
||||
*/
|
||||
async function detectIntentMode(
|
||||
client: Client,
|
||||
input: string,
|
||||
intentDetectBaseUrl?: string,
|
||||
): Promise<IntentDetectResult | null> {
|
||||
// 意图识别模型可指向独立 region/workspace;未配置时落回模型域 baseUrl。
|
||||
const url = intentDetectEndpoint(intentDetectBaseUrl ?? client.baseUrl);
|
||||
|
||||
// Build system prompt following the official template:
|
||||
// tools JSON is embedded in the prompt text, NOT passed via request body.
|
||||
const systemPrompt = buildIntentDetectSystemPrompt();
|
||||
|
||||
// DashScope-native request shape: { model, input, parameters }
|
||||
const body = {
|
||||
model: INTENT_DETECT_MODEL,
|
||||
input: {
|
||||
messages: [
|
||||
{ role: "system" as const, content: systemPrompt },
|
||||
{ role: "user" as const, content: input },
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
result_format: "message" as const,
|
||||
max_tokens: 512,
|
||||
temperature: 0,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await client.requestJson<DashScopeIntentDetectResponse>({
|
||||
path: url,
|
||||
method: "POST",
|
||||
body,
|
||||
timeout: INTENT_DETECT_TIMEOUT,
|
||||
});
|
||||
|
||||
const text = response.output?.choices?.[0]?.message?.content ?? "";
|
||||
|
||||
// 1. Extract mode from <tags>
|
||||
const tag = parseTags(text);
|
||||
const mode: PreferenceMode = VALID_MODES.includes(tag as PreferenceMode)
|
||||
? (tag as PreferenceMode)
|
||||
const mode: PreferenceMode =
|
||||
typeof raw.mode === "string" && VALID_MODES.includes(raw.mode as PreferenceMode)
|
||||
? (raw.mode as PreferenceMode)
|
||||
: "unconstrained";
|
||||
|
||||
// 2. Extract structured fields from <tool_call>
|
||||
const args = parseToolCall(text);
|
||||
const targets = args ? safeStringArray(args.targets) : [];
|
||||
const excludes = args ? safeStringArray(args.excludes) : [];
|
||||
const rawComplexity = args?.complexity;
|
||||
const complexity = rawComplexity === "pipeline" ? ("pipeline" as const) : ("single" as const);
|
||||
const targets = Array.isArray(raw.targets)
|
||||
? (raw.targets as unknown[]).filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
const excludes = Array.isArray(raw.excludes)
|
||||
? (raw.excludes as unknown[]).filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
|
||||
return { mode, targets, excludes, complexity };
|
||||
} catch {
|
||||
// detect-v3 failure is non-fatal: caller falls back to extraction model fields
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
targets: targets.length > 0 ? targets : undefined,
|
||||
excludes: excludes.length > 0 ? excludes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge detect-v3 and extraction model results into a single ModelPreference.
|
||||
* detect-v3 wins on mode/targets/excludes; extraction model is the fallback.
|
||||
*/
|
||||
function buildModelPreference(
|
||||
detect: IntentDetectResult | null,
|
||||
extractionFallback?: { mode: PreferenceMode; targets: string[]; excludes: string[] },
|
||||
): ModelPreference | undefined {
|
||||
if (detect) {
|
||||
return {
|
||||
mode: detect.mode,
|
||||
targets: detect.targets.length > 0 ? detect.targets : undefined,
|
||||
excludes: detect.excludes.length > 0 ? detect.excludes : undefined,
|
||||
};
|
||||
}
|
||||
if (extractionFallback) {
|
||||
return {
|
||||
mode: extractionFallback.mode,
|
||||
targets: extractionFallback.targets.length > 0 ? extractionFallback.targets : undefined,
|
||||
excludes: extractionFallback.excludes.length > 0 ? extractionFallback.excludes : undefined,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---- Main entry: parallel detect-v3 + qwen3.6-flash ------------------------
|
||||
|
||||
/**
|
||||
* Analyze the user's input to produce an IntentProfile.
|
||||
*
|
||||
* Two LLM calls run in parallel:
|
||||
* 1. `tongyi-intent-detect-v3` (DashScope native) -- fast mode/targets/excludes/complexity
|
||||
* 2. `qwen3.6-flash` -- rich field extraction (taskSummary, modalities, budget, etc.)
|
||||
* Calls `qwen3.6-flash` via the OpenAI-compatible chat endpoint to extract
|
||||
* structured intent fields: taskSummary, modalities, capabilities, budget,
|
||||
* qualityPreference, modelPreference (mode/targets/excludes), and more.
|
||||
*
|
||||
* detect-v3 takes priority for mode/targets/excludes/complexity;
|
||||
* the extraction model fills everything else.
|
||||
* On failure, degrades gracefully to DEFAULT_INTENT with confidence 0.
|
||||
*/
|
||||
export async function analyzeIntent(
|
||||
client: Client,
|
||||
input: string,
|
||||
opts?: { intentDetectBaseUrl?: string },
|
||||
): Promise<IntentProfile> {
|
||||
const detectPromise = detectIntentMode(client, input, opts?.intentDetectBaseUrl);
|
||||
|
||||
export async function analyzeIntent(client: Client, input: string): Promise<IntentProfile> {
|
||||
const url = chatPath();
|
||||
const body = {
|
||||
model: INTENT_EXTRACTION_MODEL,
|
||||
@@ -199,71 +62,31 @@ export async function analyzeIntent(
|
||||
temperature: 0,
|
||||
};
|
||||
|
||||
const extractionPromise = client.requestJson<ChatResponse>({
|
||||
path: url,
|
||||
method: "POST",
|
||||
body,
|
||||
timeout: 30,
|
||||
});
|
||||
|
||||
const [detectResult, extractionResponse] = await Promise.all([
|
||||
detectPromise,
|
||||
extractionPromise.catch(() => null),
|
||||
]);
|
||||
|
||||
// If extraction model failed, use detect-v3 result + defaults
|
||||
if (!extractionResponse) {
|
||||
return {
|
||||
...DEFAULT_INTENT,
|
||||
modelPreference: buildModelPreference(detectResult),
|
||||
complexity:
|
||||
detectResult?.complexity === "pipeline" ? Complexities.Pipeline : Complexities.Single,
|
||||
};
|
||||
let response: ChatResponse;
|
||||
try {
|
||||
response = await client.requestJson<ChatResponse>({
|
||||
path: url,
|
||||
method: "POST",
|
||||
body,
|
||||
timeout: 30,
|
||||
});
|
||||
} catch {
|
||||
return { ...DEFAULT_INTENT };
|
||||
}
|
||||
|
||||
const text = extractionResponse.choices?.[0]?.message?.content ?? "";
|
||||
const text = response.choices?.[0]?.message?.content ?? "";
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
return {
|
||||
...DEFAULT_INTENT,
|
||||
confidence: detectResult ? 1 : 0,
|
||||
modelPreference: buildModelPreference(detectResult),
|
||||
complexity:
|
||||
detectResult?.complexity === "pipeline" ? Complexities.Pipeline : Complexities.Single,
|
||||
};
|
||||
return { ...DEFAULT_INTENT };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
// Extraction model's mode/targets/excludes (fallback when detect-v3 is null)
|
||||
const rawPref = parsed.modelPreference as Record<string, unknown> | undefined;
|
||||
const extractionMode: PreferenceMode =
|
||||
rawPref && typeof rawPref === "object" && typeof rawPref.mode === "string"
|
||||
? VALID_MODES.includes(rawPref.mode as PreferenceMode)
|
||||
? (rawPref.mode as PreferenceMode)
|
||||
: "unconstrained"
|
||||
: "unconstrained";
|
||||
const extractionTargets: string[] =
|
||||
rawPref && typeof rawPref === "object" && Array.isArray(rawPref.targets)
|
||||
? (rawPref.targets as string[])
|
||||
: [];
|
||||
const extractionExcludes: string[] =
|
||||
rawPref && typeof rawPref === "object" && Array.isArray(rawPref.excludes)
|
||||
? (rawPref.excludes as string[])
|
||||
: [];
|
||||
const modelPreference = extractModelPreference(rawPref);
|
||||
|
||||
// Merge: detect-v3 wins, extraction model fills gaps
|
||||
const modelPreference = buildModelPreference(detectResult, {
|
||||
mode: extractionMode,
|
||||
targets: extractionTargets,
|
||||
excludes: extractionExcludes,
|
||||
});
|
||||
|
||||
// Extraction model complexity, but detect-v3 pipeline tag overrides
|
||||
const extractionComplexity =
|
||||
parsed.complexity === Complexities.Pipeline ? Complexities.Pipeline : Complexities.Single;
|
||||
const complexity =
|
||||
detectResult?.complexity === "pipeline" ? Complexities.Pipeline : extractionComplexity;
|
||||
parsed.complexity === Complexities.Pipeline ? Complexities.Pipeline : Complexities.Single;
|
||||
|
||||
return {
|
||||
complexity,
|
||||
|
||||
@@ -61,12 +61,28 @@ function normalizeStr(value: string): string {
|
||||
function matchesTarget(model: ModelProfile, target: string): boolean {
|
||||
const needle = normalizeStr(target);
|
||||
if (!needle) return false;
|
||||
// exact normalized match on id/name wins (resolves "qwen max" → "qwen-max")
|
||||
|
||||
// Tier 1: exact normalized match on model id or display name
|
||||
if (normalizeStr(model.model) === needle || normalizeStr(model.name) === needle) return true;
|
||||
// otherwise normalized substring across identifier-ish fields
|
||||
return [model.model, model.name, model.family, model.familyName, model.provider].some((field) =>
|
||||
field ? normalizeStr(field).includes(needle) : false,
|
||||
);
|
||||
|
||||
// Tier 2: suffix match for provider-prefix model ids
|
||||
// e.g. "siliconflow/deepseek-v3" → suffix "deepseek-v3" → normalize → "deepseekv3"
|
||||
const modelId = model.model;
|
||||
const slashIdx = modelId.lastIndexOf("/");
|
||||
if (slashIdx >= 0) {
|
||||
const suffix = normalizeStr(modelId.slice(slashIdx + 1));
|
||||
if (suffix === needle) return true;
|
||||
}
|
||||
|
||||
// Tier 3: substring match only on family / familyName
|
||||
// e.g. target "deepseek" matches family "DeepSeek" → normalize → "deepseek"
|
||||
// but target "deepseek-v3" does NOT match family "DeepSeek" because
|
||||
// "deepseekv3".includes("deepseek") is the wrong direction (needle ⊃ field).
|
||||
return [model.family, model.familyName].some((field) => {
|
||||
if (!field) return false;
|
||||
const normalized = normalizeStr(field);
|
||||
return normalized.length > 0 && needle.includes(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
function matchesAnyTarget(model: ModelProfile, targets: string[]): boolean {
|
||||
@@ -287,16 +303,18 @@ function recallScoped(
|
||||
|
||||
function recallComparison(
|
||||
models: ModelProfile[],
|
||||
embeddings: ModelEmbedding[],
|
||||
queryVector: number[],
|
||||
_embeddings: ModelEmbedding[],
|
||||
_queryVector: number[],
|
||||
preference: ModelPreference,
|
||||
topK: number,
|
||||
modelMap: Map<string, ModelProfile>,
|
||||
intent?: IntentProfile,
|
||||
_topK: number,
|
||||
_modelMap: Map<string, ModelProfile>,
|
||||
_intent?: IntentProfile,
|
||||
): ScoredCandidate[] {
|
||||
const targets = preference.targets ?? [];
|
||||
|
||||
// user-named models are forced in (bypass hard gate), priority 1.0
|
||||
// Comparison mode: only return the user-specified models (bypass hard gate).
|
||||
// No fusion-ranked fillers — the user explicitly asked to compare these models,
|
||||
// so extra candidates would only give the LLM ranker room to substitute them.
|
||||
const forced: ScoredCandidate[] = [];
|
||||
const forcedIds = new Set<string>();
|
||||
for (const profile of models) {
|
||||
@@ -306,19 +324,7 @@ function recallComparison(
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, topK - forced.length);
|
||||
if (remaining > 0) {
|
||||
const candidatePool = models.filter((profile) => !forcedIds.has(profile.model));
|
||||
const poolIds = filterWithFallback(candidatePool, intent);
|
||||
const extra = rankByFusion(embeddings, queryVector, poolIds, remaining, modelMap, intent);
|
||||
for (const cand of extra) {
|
||||
forced.push(cand);
|
||||
}
|
||||
}
|
||||
|
||||
// clamp in case many targets matched beyond topK (forced are first, so they
|
||||
// are preserved up to topK and extras drop first)
|
||||
return forced.slice(0, Math.max(0, topK));
|
||||
return forced;
|
||||
}
|
||||
|
||||
function recallAlternative(
|
||||
|
||||
@@ -6,19 +6,6 @@ export function chatPath(): string {
|
||||
return "/compatible-mode/v1/chat/completions";
|
||||
}
|
||||
|
||||
// ---- Intent Detect (DashScope Native) ----
|
||||
|
||||
/**
|
||||
* DashScope-native text-generation endpoint for `tongyi-intent-detect-v3`.
|
||||
* This model does not use the OpenAI-compatible chat endpoint — it requires
|
||||
* the native `{ model, input, parameters }` request shape with
|
||||
* `result_format: "message"` and returns a `{ output, usage, request_id }`
|
||||
* envelope.
|
||||
*/
|
||||
export function intentDetectEndpoint(baseUrl: string): string {
|
||||
return `${baseUrl}/api/v1/services/aigc/text-generation/generation`;
|
||||
}
|
||||
|
||||
// ---- Image Generation (DashScope) ----
|
||||
export function imagePath(): string {
|
||||
return "/api/v1/services/aigc/image-generation/generation";
|
||||
|
||||
@@ -61,8 +61,6 @@ export function buildSettings(s: ResolutionSources): Settings {
|
||||
|
||||
return {
|
||||
configPath: getConfigPath(),
|
||||
intentDetectBaseUrl:
|
||||
file.intent_detect_base_url || env.DASHSCOPE_INTENT_DETECT_BASE_URL || undefined,
|
||||
output: detectOutputFormat(flags.output || env.DASHSCOPE_OUTPUT || file.output),
|
||||
outputExplicit: Boolean(flags.output || env.DASHSCOPE_OUTPUT || file.output),
|
||||
outputDir: file.output_dir || undefined,
|
||||
|
||||
@@ -23,12 +23,6 @@ export interface ConfigFile {
|
||||
/** Alibaba Cloud OpenAPI AccessKey secret from `bl auth login --open-api`. */
|
||||
access_key_secret?: string;
|
||||
base_url?: string;
|
||||
/**
|
||||
* Dedicated base URL for the intent-detect model (tongyi-intent-detect-v3).
|
||||
* Allows pointing the intent API at a different region/workspace than the
|
||||
* main chat endpoint. Falls back to `base_url` when not set.
|
||||
*/
|
||||
intent_detect_base_url?: string;
|
||||
output?: "text" | "json";
|
||||
output_dir?: string;
|
||||
timeout?: number;
|
||||
@@ -84,8 +78,6 @@ export function parseConfigFile(raw: unknown): ConfigFile {
|
||||
)
|
||||
out.access_key_secret = obj.openapi_access_key_secret;
|
||||
if (typeof obj.base_url === "string" && isHttpUrl(obj.base_url)) out.base_url = obj.base_url;
|
||||
if (typeof obj.intent_detect_base_url === "string" && isHttpUrl(obj.intent_detect_base_url))
|
||||
out.intent_detect_base_url = obj.intent_detect_base_url;
|
||||
if (typeof obj.output === "string" && VALID_OUTPUTS.has(obj.output))
|
||||
out.output = obj.output as ConfigFile["output"];
|
||||
if (typeof obj.output_dir === "string" && obj.output_dir.length > 0)
|
||||
@@ -132,8 +124,6 @@ export interface Identity {
|
||||
*/
|
||||
export interface Settings {
|
||||
configPath?: string;
|
||||
/** Dedicated base URL for intent-detect model; falls back to the model baseUrl at call site. */
|
||||
intentDetectBaseUrl?: string;
|
||||
output: "text" | "json";
|
||||
/**
|
||||
* Whether `output` came from an explicit source (flag/env/file) rather than
|
||||
|
||||
@@ -108,51 +108,6 @@ export interface StreamChunk {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Intent Detect (DashScope Native) ----
|
||||
|
||||
/**
|
||||
* Request body for `tongyi-intent-detect-v3` via the DashScope-native
|
||||
* text-generation endpoint. Uses `{ model, input, parameters }` shape —
|
||||
* NOT the OpenAI `{ model, messages }` shape.
|
||||
*/
|
||||
export interface DashScopeIntentDetectRequest {
|
||||
model: string;
|
||||
input: {
|
||||
messages: Array<{
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
}>;
|
||||
};
|
||||
parameters?: {
|
||||
result_format?: "message";
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Response envelope from the DashScope-native text-generation endpoint with
|
||||
* `result_format: "message"`. The model's output lives under `output.choices`,
|
||||
* mirroring the OpenAI shape but nested one level deeper.
|
||||
*/
|
||||
export interface DashScopeIntentDetectResponse {
|
||||
output: {
|
||||
choices?: Array<{
|
||||
finish_reason: string;
|
||||
message: {
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
usage?: {
|
||||
total_tokens?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
};
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
// ---- Image (DashScope) ----
|
||||
|
||||
export interface DashScopeImageRequest {
|
||||
|
||||
Reference in New Issue
Block a user