Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# .gitkeep file auto-generated at 2026-07-03T20:55:45.140Z for PR creation at branch issue-275-0cfd401f04e4 for issue https://github.com/link-assistant/agent/issues/275
5 changes: 5 additions & 0 deletions js/.changeset/quiet-compaction-cascade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@link-assistant/agent': patch
---

Fix compaction cascade logging so default fallback model misses no longer emit provider error logs for single-provider configurations.
23 changes: 18 additions & 5 deletions js/src/cli/model-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,10 @@ async function resolveCompactionModelEntry(

// Short name resolution
const { Provider } = await import('../provider/provider.ts');
const resolved = await Provider.parseModelWithResolution(modelArg);
const resolved = await Provider.resolveShortModelName(modelArg);
if (!resolved) {
throw new Error('ProviderModelNotFoundError');
}
return {
providerID: resolved.providerID,
modelID: resolved.modelID,
Expand Down Expand Up @@ -336,10 +339,17 @@ async function parseCompactionModelConfig(

// Check for --compaction-models (cascade) first — it overrides --compaction-model
const cliCompactionModelsArg = getCompactionModelsFromProcessArgv();
const defaultCompactionModels = getDefaultCompactionModels(defaultOptions);
const compactionModelsSource =
cliCompactionModelsArg ||
(argv['compaction-models'] &&
argv['compaction-models'] !== defaultCompactionModels)
? 'cli'
: 'default';
const compactionModelsArg =
cliCompactionModelsArg ??
argv['compaction-models'] ??
getDefaultCompactionModels(defaultOptions);
defaultCompactionModels;

// Parse the links notation sequence into an array of model names
const modelNames = parseLinksNotationSequence(compactionModelsArg);
Expand All @@ -360,8 +370,11 @@ async function parseCompactionModelConfig(
useSameModel: resolved.useSameModel,
});
} catch (err) {
// If a model can't be resolved, log and skip it
Log.Default.warn(() => ({
const logSkip =
compactionModelsSource === 'default'
? Log.Default.debug
: Log.Default.warn;
logSkip(() => ({
message: 'skipping unresolvable compaction model in cascade',
model: name,
error: err?.message,
Expand All @@ -374,7 +387,7 @@ async function parseCompactionModelConfig(
models: compactionModels.map((m) =>
m.useSameModel ? 'same' : `${m.providerID}/${m.modelID}`
),
source: cliCompactionModelsArg ? 'cli' : 'default',
source: compactionModelsSource,
}));

// Use the first model as the primary compaction model (for backward compatibility)
Expand Down
115 changes: 115 additions & 0 deletions js/tests/compaction-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,118 @@ describe('CompactionModelConfig with cascade', () => {
expect(config.compactionModels).toBeUndefined();
});
});

describe('compaction model cascade logging', () => {
test('does not emit provider error logs for unresolvable default cascade entries', async () => {
const configContent = JSON.stringify({
$schema: 'https://opencode.ai/config.json',
disabled_providers: [
'amazon-bedrock',
'anthropic',
'azure',
'claude-oauth',
'github-copilot',
'github-copilot-enterprise',
'google',
'google-vertex',
'google-vertex-anthropic',
'groq',
'kilo',
'link-assistant',
'link-assistant/cache',
'mistral',
'opencode',
'openai',
'openrouter',
'xai',
],
provider: {
formalai: {
name: 'local',
npm: '@ai-sdk/openai-compatible',
options: {
baseURL: 'http://127.0.0.1:8080/api/openai/v1',
apiKey: '{env:FORMAL_AI_API_KEY}',
},
models: {
'formal-ai': {
name: 'formal-ai',
},
},
},
},
model: 'formalai/formal-ai',
});

const script = `
import { parseModelConfig } from './src/cli/model-config.js';
import { initConfig, resetConfig } from './src/config/config.ts';
import { Log } from './src/util/log.ts';
import { Instance } from './src/project/instance.ts';

process.argv = ['bun', 'agent', '--model', 'formalai/formal-ai', '--verbose'];
resetConfig();
initConfig(process.argv);
await Log.init({ print: true, level: 'DEBUG', compactJson: true });
await Instance.provide({
directory: process.cwd(),
fn: async () => {
await parseModelConfig(
{ model: 'formalai/formal-ai' },
() => {},
() => {}
);
},
});
await Instance.disposeAll();
`;

const proc = Bun.spawn({
cmd: ['bun', '--eval', script],
cwd: process.cwd(),
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
FORMAL_AI_API_KEY: 'formal-ai',
LINK_ASSISTANT_AGENT_CONFIG_CONTENT: configContent,
},
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);

if (exitCode !== 0) {
throw new Error(`parseModelConfig child process failed: ${stderr}`);
}

const logs = stdout
.split('\n')
.filter(Boolean)
.flatMap((line) => {
try {
return [JSON.parse(line)];
} catch {
return [];
}
});
const skippedCascadeModels = logs.filter(
(log) =>
log.message === 'skipping unresolvable compaction model in cascade'
);

expect(skippedCascadeModels.length).toBeGreaterThan(0);
expect(skippedCascadeModels.every((log) => log.level === 'debug')).toBe(
true
);
expect(
logs.some(
(log) =>
log.level === 'error' &&
log.message === 'model not found - refusing to silently fallback'
)
).toBe(false);
});
});
Loading