fix: prioritize direct providers for goal patch recovery

This commit is contained in:
thanhnv
2026-07-18 10:32:06 +07:00
parent 049f0c9b04
commit e17a373da6
5 changed files with 84 additions and 14 deletions
+6
View File
@@ -28,6 +28,12 @@ services:
CASAN_OPENAI_COMPATIBLE_BASE_URL: ${CASAN_OPENAI_COMPATIBLE_BASE_URL:-} CASAN_OPENAI_COMPATIBLE_BASE_URL: ${CASAN_OPENAI_COMPATIBLE_BASE_URL:-}
CASAN_OPENAI_COMPATIBLE_API_KEY: ${CASAN_OPENAI_COMPATIBLE_API_KEY:-} CASAN_OPENAI_COMPATIBLE_API_KEY: ${CASAN_OPENAI_COMPATIBLE_API_KEY:-}
CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS: ${CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS:-} CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS: ${CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS:-}
# Direct provider credentials are separate from the OpenAI-compatible
# gateway; a gateway key must never be assumed valid for api.openai.com.
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
CASAN_GOAL_OPENAI_MODEL: ${CASAN_GOAL_OPENAI_MODEL:-gpt-4o-mini}
CASAN_GOAL_ANTHROPIC_MODEL: ${CASAN_GOAL_ANTHROPIC_MODEL:-claude-3-5-sonnet-latest}
CASAN_PREFLIGHT: ${CASAN_PREFLIGHT:-0} CASAN_PREFLIGHT: ${CASAN_PREFLIGHT:-0}
CASAN_AUTH_BRIDGE_URL: http://host.docker.internal:20130 CASAN_AUTH_BRIDGE_URL: http://host.docker.internal:20130
CASAN_AUTH_BRIDGE_TOKEN: ${CASAN_AUTH_BRIDGE_TOKEN:-} CASAN_AUTH_BRIDGE_TOKEN: ${CASAN_AUTH_BRIDGE_TOKEN:-}
+6
View File
@@ -6,3 +6,9 @@ CASAN_CHAT_MODEL_PROVIDER=omniroute
CASAN_OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:20128/v1 CASAN_OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:20128/v1
CASAN_OPENAI_COMPATIBLE_API_KEY=replace-with-your-omniroute-or-openai-key CASAN_OPENAI_COMPATIBLE_API_KEY=replace-with-your-omniroute-or-openai-key
CASAN_PREFLIGHT=1 CASAN_PREFLIGHT=1
# Official providers are separate from the compatible gateway and are preferred
# in governed goal review and H2 patch-format recovery when configured.
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
CASAN_GOAL_OPENAI_MODEL=gpt-4o-mini
CASAN_GOAL_ANTHROPIC_MODEL=claude-3-5-sonnet-latest
@@ -129,32 +129,46 @@ export class GoalsService {
const connections = this.connections(actor); const connections = this.connections(actor);
const local = connections.find((connection) => connection.connected && connection.kind === 'local'); const local = connections.find((connection) => connection.connected && connection.kind === 'local');
const cloudConnections = connections.filter((connection) => connection.connected && connection.kind === 'cloud' && (connection.defaultModel || connection.models[0])); const cloudConnections = connections
.filter((connection) => connection.connected && connection.kind === 'cloud' && (connection.defaultModel || connection.models[0]))
.sort((left, right) => ({ openai: 0, anthropic: 1 }[left.id] ?? 9) - ({ openai: 0, anthropic: 1 }[right.id] ?? 9));
const cloud = cloudConnections[0]; const cloud = cloudConnections[0];
const gateway = connections.find((connection) => connection.connected && connection.kind === 'gateway'); const gateway = connections.find((connection) => connection.connected && connection.kind === 'gateway');
const account = await this.accountReviewer(); const account = await this.accountReviewer();
const localModel = local?.defaultModel || local?.models[0] || 'ornith:9b'; const localModel = local?.defaultModel || local?.models[0] || 'ornith:9b';
const cloudModel = cloud?.defaultModel || cloud?.models[0] || gateway?.defaultModel || gateway?.models[0] || '';
const localRuntime = local ? this.localRuntime(this.runtime(local.id, localModel, actor)) : { const localRuntime = local ? this.localRuntime(this.runtime(local.id, localModel, actor)) : {
CASAN_CHAT_SELECTED_MODEL: `ollama:${localModel}`, CASAN_CHAT_SELECTED_MODEL: `ollama:${localModel}`,
CASAN_OLLAMA_HOST: process.env.CASAN_OLLAMA_HOST || 'host.docker.internal:11434', CASAN_OLLAMA_HOST: process.env.CASAN_OLLAMA_HOST || 'host.docker.internal:11434',
OLLAMA_HOST: process.env.OLLAMA_HOST || 'host.docker.internal:11434', OLLAMA_HOST: process.env.OLLAMA_HOST || 'host.docker.internal:11434',
}; };
const selectedReviewer = cloud ?? gateway; const selectedReviewer = cloud ?? gateway;
const cloudRuntime = selectedReviewer ? this.runtime(selectedReviewer.id, cloudModel, actor) : {}; // Runtime credentials for a saved connection must use that connection's
// discovered model, even when an environment-provided direct key has won
// priority for this run.
const cloudRuntime = cloud ? this.runtime(cloud.id, cloud.defaultModel || cloud.models[0] || '', actor) : {};
const gatewayRuntime = gateway const gatewayRuntime = gateway
? this.runtime(gateway.id, gateway.defaultModel || gateway.models[0] || '', actor) ? this.runtime(gateway.id, gateway.defaultModel || gateway.models[0] || '', actor)
: {}; : {};
const gatewayCredentials = Object.fromEntries( const gatewayCredentials = Object.fromEntries(
Object.entries(gatewayRuntime).filter(([key]) => key.startsWith('CASAN_OPENAI_COMPATIBLE_')), Object.entries(gatewayRuntime).filter(([key]) => key.startsWith('CASAN_OPENAI_COMPATIBLE_')),
); );
const cloudCandidates = cloudConnections.map((connection) => { const savedCloudCandidates = cloudConnections.map((connection) => {
const model = connection.defaultModel || connection.models[0]; const model = connection.defaultModel || connection.models[0];
const runtime = this.runtime(connection.id, model, actor); const runtime = this.runtime(connection.id, model, actor);
return { model: String(runtime.CASAN_CHAT_SELECTED_MODEL || model), runtime }; return { model: String(runtime.CASAN_CHAT_SELECTED_MODEL || model), runtime };
}); });
const cloudCredentials = Object.assign({}, ...cloudCandidates.map(({ runtime }) => runtime)); const envCloudCandidates = [
const gatewayModels = gateway?.models.slice(0, 5).map((model) => `openai-compatible:${model}`) ?? []; process.env.OPENAI_API_KEY ? { model: `openai:${process.env.CASAN_GOAL_OPENAI_MODEL || 'gpt-4o-mini'}`, runtime: {} } : null,
process.env.ANTHROPIC_API_KEY ? { model: `anthropic:${process.env.CASAN_GOAL_ANTHROPIC_MODEL || 'claude-3-5-sonnet-latest'}`, runtime: {} } : null,
].filter((candidate): candidate is { model: string; runtime: Record<string, string> } => candidate !== null);
const cloudCandidates = [...envCloudCandidates, ...savedCloudCandidates].filter((candidate, index, rows) => rows.findIndex((row) => row.model === candidate.model) === index);
const cloudCredentials = Object.assign({}, ...savedCloudCandidates.map(({ runtime }) => runtime));
const gatewayModels = gateway
? [gateway.defaultModel, ...gateway.models].filter((model, index, rows) => Boolean(model) && rows.indexOf(model) === index).slice(0, 5).map((model) => `openai-compatible:${model}`)
: [];
const preferredCloudModel = cloudCandidates[0]?.model || '';
const preferredCloudProvider = preferredCloudModel.startsWith('openai:') ? 'openai' : preferredCloudModel.startsWith('anthropic:') ? 'anthropic' : (cloud?.id || 'unavailable');
const cloudModel = preferredCloudModel || gateway?.defaultModel || gateway?.models[0] || '';
const id = randomUUID(); const id = randomUUID();
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const job: GoalJob = { const job: GoalJob = {
@@ -170,11 +184,11 @@ export class GoalsService {
updated_at: timestamp, updated_at: timestamp,
local_provider: local?.id || 'local-policy', local_provider: local?.id || 'local-policy',
local_model: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`), local_model: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
cloud_provider: account ? `${account}-account` : (selectedReviewer?.id || 'unavailable'), cloud_provider: account ? `${account}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'unavailable')),
cloud_model: account ? `${account}-account-default` : String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''), cloud_model: account ? `${account}-account-default` : preferredCloudModel || String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
stages: [ stages: [
{ id: 'local-worker', status: 'queued', detail: 'Waiting for local worker', provider: local?.id || 'local-policy', model: localModel }, { id: 'local-worker', status: 'queued', detail: 'Waiting for local worker', provider: local?.id || 'local-policy', model: localModel },
{ id: 'cloud-reviewer', status: 'queued', detail: account || selectedReviewer ? 'Waiting for independent reviewer' : 'Cloud unavailable; local reviewer will be used', provider: account ? `${account}-account` : (selectedReviewer?.id || 'local-policy'), model: account ? `${account}-account-default` : cloudModel || localModel }, { id: 'cloud-reviewer', status: 'queued', detail: account || preferredCloudModel || selectedReviewer ? 'Waiting for independent reviewer' : 'Cloud unavailable; local reviewer will be used', provider: account ? `${account}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'local-policy')), model: account ? `${account}-account-default` : cloudModel || localModel },
], ],
}; };
const jobFile = this.jobPath(actor.tenant, id); const jobFile = this.jobPath(actor.tenant, id);
@@ -199,6 +213,8 @@ export class GoalsService {
CASAN_GOAL_CLOUD_FALLBACK_MODEL: String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''), CASAN_GOAL_CLOUD_FALLBACK_MODEL: String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
CASAN_GOAL_CLOUD_MODELS: cloudCandidates.map(({ model }) => model).join(','), CASAN_GOAL_CLOUD_MODELS: cloudCandidates.map(({ model }) => model).join(','),
CASAN_GOAL_OMNIROUTE_MODELS: gatewayModels.join(','), CASAN_GOAL_OMNIROUTE_MODELS: gatewayModels.join(','),
// H2 recovery tries direct cloud credentials before gateway and local.
CASAN_GOAL_PATCH_REPAIR_MODELS: [...cloudCandidates.map(({ model }) => model), ...gatewayModels, String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`)].filter((model, index, rows) => rows.indexOf(model) === index).join(','),
CASAN_GOAL_LOCAL_REVIEWER_MODEL: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`), CASAN_GOAL_LOCAL_REVIEWER_MODEL: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
CASAN_GOAL_REVIEWER_MAX_ATTEMPTS: process.env.CASAN_GOAL_REVIEWER_MAX_ATTEMPTS || '8', CASAN_GOAL_REVIEWER_MAX_ATTEMPTS: process.env.CASAN_GOAL_REVIEWER_MAX_ATTEMPTS || '8',
CASAN_GOAL_REVIEWER_DEADLINE_SEC: process.env.CASAN_GOAL_REVIEWER_DEADLINE_SEC || '600', CASAN_GOAL_REVIEWER_DEADLINE_SEC: process.env.CASAN_GOAL_REVIEWER_DEADLINE_SEC || '600',
@@ -286,6 +286,29 @@ def patch_repair_attempts() -> int:
return min(max(configured, 0), 1) return min(max(configured, 0), 1)
def patch_repair_models(primary_model: str):
"""Use direct cloud -> gateway -> local order for one bounded H2 recovery."""
configured = [item.strip() for item in os.environ.get("CASAN_GOAL_PATCH_REPAIR_MODELS", "").split(",") if item.strip()]
unique, seen = [], set()
for candidate in configured + [primary_model]:
if candidate and candidate not in seen:
seen.add(candidate)
unique.append(candidate)
return unique
def provider_for_model(model: str) -> str:
if model.startswith("openai:"):
return "openai"
if model.startswith("anthropic:"):
return "anthropic"
if model.startswith("openai-compatible:"):
return "omniroute"
if model.startswith("ollama:"):
return "ollama"
return "model"
def repair_write_output(model: str, original_prompt: str, invalid_output: str): def repair_write_output(model: str, original_prompt: str, invalid_output: str):
"""Ask the producing model to repair format only, without relaxing H2. """Ask the producing model to repair format only, without relaxing H2.
@@ -303,7 +326,11 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str):
"Do not explain, plan, summarize, use placeholders, or perform side effects. Preserve the original objective and workspace restrictions.\n\n" "Do not explain, plan, summarize, use placeholders, or perform side effects. Preserve the original objective and workspace restrictions.\n\n"
f"ORIGINAL CONTRACT:\n{original_prompt}\n\nPREVIOUS INVALID RESPONSE:\n{invalid_output[:8000]}" f"ORIGINAL CONTRACT:\n{original_prompt}\n\nPREVIOUS INVALID RESPONSE:\n{invalid_output[:8000]}"
) )
ok, output, metadata, reason = call_model(model, repair_prompt, False) candidates = patch_repair_models(model)
candidate = candidates[0] if candidates else model
ok, output, metadata, reason = call_model(candidate, repair_prompt, candidate.startswith(("openai:", "anthropic:", "openai-compatible:")))
metadata = dict(metadata)
metadata["repair_model"] = candidate
if not ok: if not ok:
return False, "", metadata, f"goal_patch_repair_failed:{reason}" return False, "", metadata, f"goal_patch_repair_failed:{reason}"
return True, output, metadata, "ok" return True, output, metadata, "ok"
@@ -697,8 +724,10 @@ def run(job_path: str) -> int:
try: try:
validate_write_output(safe_local) validate_write_output(safe_local)
except ValueError as first_error: except ValueError as first_error:
stage(job_path, "local-worker", "running", "Repairing invalid patch output contract", job.get("local_provider", ""), local_model) repair_model = patch_repair_models(local_model)[0] if patch_repair_models(local_model) else local_model
emit(goal_id, "H2-tool", "running", "Local worker is repairing patch output contract", {"reason": str(first_error), "max_attempts": patch_repair_attempts()}) repair_provider = provider_for_model(repair_model)
stage(job_path, "local-worker", "running", "Repairing invalid patch output contract", repair_provider, repair_model)
emit(goal_id, "H2-tool", "running", "Worker is repairing patch output contract", {"reason": str(first_error), "max_attempts": patch_repair_attempts(), "repair_provider": repair_provider, "repair_model": repair_model})
repaired, repaired_output, repaired_meta, repair_reason = repair_write_output(local_model, local_prompt, safe_local) repaired, repaired_output, repaired_meta, repair_reason = repair_write_output(local_model, local_prompt, safe_local)
local_meta = merge_usage(local_meta, repaired_meta) local_meta = merge_usage(local_meta, repaired_meta)
if not repaired: if not repaired:
@@ -714,8 +743,9 @@ def run(job_path: str) -> int:
except ValueError as repair_error: except ValueError as repair_error:
reason = f"goal_patch_repair_invalid:{repair_error}" reason = f"goal_patch_repair_invalid:{repair_error}"
if reason: if reason:
stage(job_path, "local-worker", "error", reason, job.get("local_provider", ""), local_model) actual_repair_model = str(repaired_meta.get("repair_model") or repair_model)
emit(goal_id, "H2-tool", "error", "Local worker violated patch output contract after repair", {"reason": reason}) stage(job_path, "local-worker", "error", reason, provider_for_model(actual_repair_model), actual_repair_model)
emit(goal_id, "H2-tool", "error", "Worker violated patch output contract after repair", {"reason": reason, "repair_provider": provider_for_model(actual_repair_model), "repair_model": actual_repair_model})
raise ValueError(reason) raise ValueError(reason)
stage(job_path, "local-worker", "pass", "Primary solution prepared", job.get("local_provider", ""), local_model) stage(job_path, "local-worker", "pass", "Primary solution prepared", job.get("local_provider", ""), local_model)
update_job(job_path, local_draft=safe_local, local_usage=local_meta) update_job(job_path, local_draft=safe_local, local_usage=local_meta)
@@ -57,6 +57,18 @@ class GoalPatchWorkflowTests(unittest.TestCase):
self.assertEqual(reason, "goal_patch_missing") self.assertEqual(reason, "goal_patch_missing")
call.assert_not_called() call.assert_not_called()
def test_repair_prefers_direct_cloud_over_gateway_and_local(self):
repaired = "diff --git a/apps/okr/frontend/a.ts b/apps/okr/frontend/a.ts\n--- a/apps/okr/frontend/a.ts\n+++ b/apps/okr/frontend/a.ts\n@@ -1 +1 @@\n-a\n+b\n"
with (
patch.dict(os.environ, {"CASAN_GOAL_PATCH_REPAIR_ATTEMPTS": "1", "CASAN_GOAL_PATCH_REPAIR_MODELS": "openai:gpt-4o-mini,anthropic:claude,openai-compatible:auto/coding,ollama:ornith"}, clear=False),
patch.object(ORCHESTRATOR, "call_model", return_value=(True, repaired, {}, "ok")) as call,
):
ok, _, usage, reason = ORCHESTRATOR.repair_write_output("ollama:ornith", "original", "plan")
self.assertTrue(ok)
self.assertEqual(reason, "ok")
self.assertEqual(usage["repair_model"], "openai:gpt-4o-mini")
self.assertEqual(call.call_args.args[0], "openai:gpt-4o-mini")
def test_patch_outside_workspace_is_denied(self): def test_patch_outside_workspace_is_denied(self):
job = {"workspace": {"context_roots": ["apps/okr/frontend"]}} job = {"workspace": {"context_roots": ["apps/okr/frontend"]}}
content = "diff --git a/package.json b/package.json\n--- a/package.json\n+++ b/package.json\n@@ -1 +1 @@\n-a\n+b\n" content = "diff --git a/package.json b/package.json\n--- a/package.json\n+++ b/package.json\n@@ -1 +1 @@\n-a\n+b\n"