fix: prioritize direct providers for goal patch recovery
This commit is contained in:
@@ -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,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"
|
||||||
|
|||||||
Reference in New Issue
Block a user