348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import type { AgentConfigRegistry } from '../agents/registry.js';
|
|
import {
|
|
loadStoredAnthropicAuth,
|
|
loadStoredAnthropicAuthToken,
|
|
loadStoredOpenAIApiKey,
|
|
loadStoredOpenAIAuth,
|
|
loadStoredZaiAuth,
|
|
} from '../auth/index.js';
|
|
import type { Config, ModelConfig, ModelProvider } from '../config/index.js';
|
|
import type { ModelTier } from '../models/router.js';
|
|
import type { ChatResponseFormat } from '../models/types.js';
|
|
|
|
interface DelegateRunner {
|
|
delegate(request: {
|
|
tier: ModelTier;
|
|
systemPrompt: string;
|
|
message: string;
|
|
maxTokens?: number;
|
|
responseFormat?: ChatResponseFormat;
|
|
}): Promise<{
|
|
content: string;
|
|
usage: { inputTokens: number; outputTokens: number };
|
|
tier: ModelTier;
|
|
}>;
|
|
}
|
|
|
|
interface CredentialState {
|
|
openaiOAuth: boolean;
|
|
openaiApiKeyStored: boolean;
|
|
anthropicApiKeyStored: boolean;
|
|
anthropicAuthTokenStored: boolean;
|
|
zaiStored: boolean;
|
|
}
|
|
|
|
export interface CouncilPreflightOptions {
|
|
config: Config;
|
|
registry: AgentConfigRegistry;
|
|
delegateRunner: DelegateRunner;
|
|
activeTier?: ModelTier;
|
|
includeLiveProbe?: boolean;
|
|
credentialState?: CredentialState;
|
|
}
|
|
|
|
interface TierConfigResolution {
|
|
modelConfig: ModelConfig;
|
|
fellBackToDefault: boolean;
|
|
}
|
|
|
|
interface AuthResolution {
|
|
mode: string;
|
|
source: string;
|
|
note?: string;
|
|
}
|
|
|
|
const DEFAULT_ENDPOINTS: Partial<Record<ModelProvider, string>> = {
|
|
openai: 'https://api.openai.com/v1',
|
|
openrouter: 'https://openrouter.ai/api/v1',
|
|
vercel: 'https://ai-gateway.vercel.sh/v1',
|
|
zhipuai: 'https://api.z.ai/api/paas/v4',
|
|
xai: 'https://api.x.ai/v1',
|
|
minimax: 'https://api.minimax.io/v1',
|
|
moonshot: 'https://api.moonshot.cn/v1',
|
|
ollama: 'http://localhost:11434',
|
|
llamacpp: 'http://localhost:8080',
|
|
};
|
|
|
|
function getCredentialStateFromSystem(): CredentialState {
|
|
return {
|
|
openaiOAuth: Boolean(loadStoredOpenAIAuth()),
|
|
openaiApiKeyStored: Boolean(loadStoredOpenAIApiKey()),
|
|
anthropicApiKeyStored: Boolean(loadStoredAnthropicAuth()),
|
|
anthropicAuthTokenStored: Boolean(loadStoredAnthropicAuthToken()),
|
|
zaiStored: Boolean(loadStoredZaiAuth()),
|
|
};
|
|
}
|
|
|
|
function getEffectiveAuthMode(cfg: ModelConfig): 'auto' | 'api_key' | 'oauth' {
|
|
if (cfg.auth_mode) {
|
|
return cfg.auth_mode;
|
|
}
|
|
if (cfg.use_oauth) {
|
|
return 'oauth';
|
|
}
|
|
return 'auto';
|
|
}
|
|
|
|
function resolveTierConfig(config: Config, tier: ModelTier): TierConfigResolution {
|
|
if (tier === 'default') {
|
|
return { modelConfig: config.models.default, fellBackToDefault: false };
|
|
}
|
|
const direct = config.models[tier];
|
|
if (direct) {
|
|
return { modelConfig: direct, fellBackToDefault: false };
|
|
}
|
|
return { modelConfig: config.models.default, fellBackToDefault: true };
|
|
}
|
|
|
|
function firstTruthySource(sources: Array<[boolean, string]>): string {
|
|
for (const [present, label] of sources) {
|
|
if (present) {
|
|
return label;
|
|
}
|
|
}
|
|
return 'missing';
|
|
}
|
|
|
|
function resolveAuth(cfg: ModelConfig, credentialState: CredentialState): AuthResolution {
|
|
if (cfg.provider === 'zhipuai') {
|
|
const source = firstTruthySource([
|
|
[Boolean(cfg.api_key?.trim()), 'config.api_key'],
|
|
[Boolean(cfg.auth_token?.trim()), 'config.auth_token'],
|
|
[Boolean(process.env.ZAI_API_KEY?.trim()), 'env:ZAI_API_KEY'],
|
|
[Boolean(process.env.ZHIPUAI_API_KEY?.trim()), 'env:ZHIPUAI_API_KEY'],
|
|
[Boolean(process.env.ZHIPUAI_AUTH_TOKEN?.trim()), 'env:ZHIPUAI_AUTH_TOKEN'],
|
|
[credentialState.zaiStored, 'auth.json:zai'],
|
|
]);
|
|
const note = cfg.use_oauth || cfg.auth_mode === 'oauth'
|
|
? 'use_oauth/auth_mode=oauth is ignored for zhipuai'
|
|
: undefined;
|
|
return {
|
|
mode: 'bearer_credential',
|
|
source,
|
|
note,
|
|
};
|
|
}
|
|
|
|
if (cfg.provider === 'openai') {
|
|
const authMode = getEffectiveAuthMode(cfg);
|
|
const apiSource = firstTruthySource([
|
|
[Boolean(cfg.api_keys && cfg.api_keys.length > 0), 'config.api_keys'],
|
|
[Boolean(cfg.api_key?.trim()), 'config.api_key'],
|
|
[Boolean(process.env.OPENAI_API_KEY?.trim()), 'env:OPENAI_API_KEY'],
|
|
[credentialState.openaiApiKeyStored, 'auth.json:openai.api_key'],
|
|
]);
|
|
const oauthSource = credentialState.openaiOAuth ? 'auth.json:openai.oauth' : 'missing';
|
|
|
|
if (authMode === 'oauth') {
|
|
return { mode: 'oauth', source: oauthSource };
|
|
}
|
|
if (authMode === 'api_key') {
|
|
return { mode: 'api_key', source: apiSource };
|
|
}
|
|
if (apiSource !== 'missing') {
|
|
return { mode: 'auto->api_key', source: apiSource };
|
|
}
|
|
return { mode: 'auto->oauth', source: oauthSource };
|
|
}
|
|
|
|
if (cfg.provider === 'anthropic') {
|
|
const authMode = getEffectiveAuthMode(cfg);
|
|
const apiSource = firstTruthySource([
|
|
[Boolean(cfg.api_keys && cfg.api_keys.length > 0), 'config.api_keys'],
|
|
[Boolean(cfg.api_key?.trim()), 'config.api_key'],
|
|
[Boolean(process.env.ANTHROPIC_API_KEY?.trim()), 'env:ANTHROPIC_API_KEY'],
|
|
[credentialState.anthropicApiKeyStored, 'auth.json:anthropic.api_key'],
|
|
]);
|
|
const oauthSource = firstTruthySource([
|
|
[Boolean(cfg.auth_token?.trim()), 'config.auth_token'],
|
|
[Boolean(process.env.ANTHROPIC_AUTH_TOKEN?.trim()), 'env:ANTHROPIC_AUTH_TOKEN'],
|
|
[credentialState.anthropicAuthTokenStored, 'auth.json:anthropic.auth_token'],
|
|
]);
|
|
|
|
if (authMode === 'oauth') {
|
|
return { mode: 'oauth', source: oauthSource };
|
|
}
|
|
if (authMode === 'api_key') {
|
|
return { mode: 'api_key', source: apiSource };
|
|
}
|
|
if (apiSource !== 'missing') {
|
|
return { mode: 'auto->api_key', source: apiSource };
|
|
}
|
|
return { mode: 'auto->oauth', source: oauthSource };
|
|
}
|
|
|
|
if (cfg.provider === 'gemini') {
|
|
const source = firstTruthySource([
|
|
[Boolean(cfg.api_key?.trim()), 'config.api_key'],
|
|
[Boolean(process.env.GEMINI_API_KEY?.trim()), 'env:GEMINI_API_KEY'],
|
|
[Boolean(process.env.GOOGLE_API_KEY?.trim()), 'env:GOOGLE_API_KEY'],
|
|
]);
|
|
return { mode: 'api_key', source };
|
|
}
|
|
|
|
if (cfg.provider === 'bedrock') {
|
|
const source = firstTruthySource([
|
|
[Boolean(cfg.api_key?.trim() && cfg.auth_token?.trim()), 'config.api_key+auth_token'],
|
|
[Boolean(process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()), 'env:AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY'],
|
|
]);
|
|
return { mode: 'aws_credentials', source };
|
|
}
|
|
|
|
if (cfg.provider === 'ollama' || cfg.provider === 'llamacpp' || cfg.provider === 'synthetic') {
|
|
return { mode: 'local', source: 'none' };
|
|
}
|
|
|
|
const source = firstTruthySource([
|
|
[Boolean(cfg.api_keys && cfg.api_keys.length > 0), 'config.api_keys'],
|
|
[Boolean(cfg.api_key?.trim()), 'config.api_key'],
|
|
]);
|
|
return { mode: 'api_key', source };
|
|
}
|
|
|
|
function resolveEndpoint(cfg: ModelConfig): string {
|
|
if (cfg.endpoint?.trim()) {
|
|
return cfg.endpoint.trim();
|
|
}
|
|
return DEFAULT_ENDPOINTS[cfg.provider] ?? '(provider default)';
|
|
}
|
|
|
|
function normalizeProbeError(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
function isPreflightRequest(task: string): boolean {
|
|
return task.trim().toLowerCase() === 'preflight';
|
|
}
|
|
|
|
export function shouldRunCouncilPreflight(task: string): boolean {
|
|
return isPreflightRequest(task);
|
|
}
|
|
|
|
export async function buildCouncilPreflightReport(options: CouncilPreflightOptions): Promise<string> {
|
|
const { config, registry, delegateRunner } = options;
|
|
const councils = config.councils;
|
|
const credentialState = options.credentialState ?? getCredentialStateFromSystem();
|
|
|
|
const roles = [
|
|
{ role: 'D.arbiter', agent: councils.groups.D.arbiter_agent, tierOverride: councils.groups.D.model_tier },
|
|
{ role: 'D.freethinker', agent: councils.groups.D.freethinker_agent, tierOverride: councils.groups.D.model_tier },
|
|
...(councils.groups.D.grounder_agent
|
|
? [{ role: 'D.grounder', agent: councils.groups.D.grounder_agent, tierOverride: councils.groups.D.model_tier }]
|
|
: []),
|
|
...(councils.groups.D.writer_agent
|
|
? [{ role: 'D.writer', agent: councils.groups.D.writer_agent, tierOverride: councils.groups.D.model_tier }]
|
|
: []),
|
|
{ role: 'P.arbiter', agent: councils.groups.P.arbiter_agent, tierOverride: councils.groups.P.model_tier },
|
|
{ role: 'P.freethinker', agent: councils.groups.P.freethinker_agent, tierOverride: councils.groups.P.model_tier },
|
|
...(councils.groups.P.grounder_agent
|
|
? [{ role: 'P.grounder', agent: councils.groups.P.grounder_agent, tierOverride: councils.groups.P.model_tier }]
|
|
: []),
|
|
...(councils.groups.P.writer_agent
|
|
? [{ role: 'P.writer', agent: councils.groups.P.writer_agent, tierOverride: councils.groups.P.model_tier }]
|
|
: []),
|
|
{ role: 'Meta.arbiter', agent: councils.meta_arbiter_agent, tierOverride: councils.meta_model_tier },
|
|
...(councils.meta_writer_agent
|
|
? [{ role: 'Meta.writer', agent: councils.meta_writer_agent, tierOverride: councils.meta_model_tier }]
|
|
: []),
|
|
];
|
|
|
|
const roleLines: string[] = [];
|
|
const tiersToProbe = new Set<ModelTier>();
|
|
|
|
for (const role of roles) {
|
|
const agentConfig = registry.get(role.agent);
|
|
const agentTier = agentConfig?.modelTier ?? 'default';
|
|
const effectiveTier = role.tierOverride ?? agentTier;
|
|
const { modelConfig, fellBackToDefault } = resolveTierConfig(config, effectiveTier);
|
|
const modelLabel = `${modelConfig.provider}/${modelConfig.model}`;
|
|
const missingSuffix = agentConfig ? '' : ' [agent_missing]';
|
|
const fallbackSuffix = fellBackToDefault ? ' [tier_unconfigured->default]' : '';
|
|
roleLines.push(
|
|
`- ${role.role}: agent=${role.agent}${missingSuffix} ` +
|
|
`agent_tier=${agentTier} override_tier=${role.tierOverride ?? 'none'} ` +
|
|
`effective_tier=${effectiveTier} model=${modelLabel}${fallbackSuffix}`,
|
|
);
|
|
tiersToProbe.add(effectiveTier);
|
|
}
|
|
|
|
const tierLines: string[] = [];
|
|
for (const tier of [...tiersToProbe].sort()) {
|
|
const { modelConfig, fellBackToDefault } = resolveTierConfig(config, tier);
|
|
const auth = resolveAuth(modelConfig, credentialState);
|
|
const endpoint = resolveEndpoint(modelConfig);
|
|
tierLines.push(
|
|
`- ${tier}: provider=${modelConfig.provider} model=${modelConfig.model} ` +
|
|
`endpoint=${endpoint} auth_mode=${auth.mode} auth_source=${auth.source}` +
|
|
`${auth.note ? ` note=${auth.note}` : ''}` +
|
|
`${fellBackToDefault ? ' fallback=default' : ''}`,
|
|
);
|
|
}
|
|
|
|
const probeLines: string[] = [];
|
|
if (options.includeLiveProbe ?? true) {
|
|
const probeResults = await Promise.all(
|
|
[...tiersToProbe].sort().map(async (tier) => {
|
|
const startedAt = Date.now();
|
|
try {
|
|
await delegateRunner.delegate({
|
|
tier,
|
|
systemPrompt: 'Return exactly {"ok":true}.',
|
|
message: 'Return exactly {"ok":true}.',
|
|
maxTokens: 64,
|
|
});
|
|
return {
|
|
tier,
|
|
ok: true,
|
|
latencyMs: Math.max(0, Date.now() - startedAt),
|
|
error: '',
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
tier,
|
|
ok: false,
|
|
latencyMs: Math.max(0, Date.now() - startedAt),
|
|
error: normalizeProbeError(error),
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
for (const result of probeResults) {
|
|
if (result.ok) {
|
|
probeLines.push(`- ${result.tier}: ok (${result.latencyMs}ms)`);
|
|
} else {
|
|
probeLines.push(`- ${result.tier}: failed (${result.latencyMs}ms) ${result.error}`);
|
|
}
|
|
}
|
|
} else {
|
|
probeLines.push('- skipped');
|
|
}
|
|
|
|
const activeTier = options.activeTier ?? 'default';
|
|
const activeModel = resolveTierConfig(config, activeTier).modelConfig;
|
|
|
|
return [
|
|
'[Council preflight]',
|
|
`Councils enabled: ${councils.enabled ? 'yes' : 'no'}`,
|
|
`Active interactive tier: ${activeTier} (${activeModel.provider}/${activeModel.model})`,
|
|
`Scaffold path: ${councils.scaffold_path?.trim() ? councils.scaffold_path : '(none)'}`,
|
|
'',
|
|
'Role routing:',
|
|
...roleLines,
|
|
'',
|
|
'Tier provider/auth:',
|
|
...tierLines,
|
|
'',
|
|
'Live tier probes:',
|
|
...probeLines,
|
|
'',
|
|
'Path comparison:',
|
|
'- Interactive TUI messages use the active tier above.',
|
|
'- Council calls use per-role effective tiers (group override, then agent tier).',
|
|
].join('\n');
|
|
}
|