multi-forge 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Verb-level cost attribution via proxy metric snapshot deltas.
|
|
2
|
+
|
|
3
|
+
Wraps subprocess invocations (panel, supervisor, handoff, etc.) to
|
|
4
|
+
measure cost by snapshotting proxy metrics before and after execution.
|
|
5
|
+
Results are logged to PID-sharded verb JSONL files.
|
|
6
|
+
|
|
7
|
+
All verb costs are marked ``estimated`` because concurrent proxy traffic
|
|
8
|
+
(e.g., the main interactive session) may share the same proxy during
|
|
9
|
+
the measurement window.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.request
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import asdict, dataclass, field
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from forge.core.paths import get_forge_home
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_verb_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ProxyCostDelta:
|
|
35
|
+
"""Cost delta for a single proxy between two snapshots."""
|
|
36
|
+
|
|
37
|
+
base_url: str
|
|
38
|
+
cost_micros: int = 0
|
|
39
|
+
input_tokens: int = 0
|
|
40
|
+
output_tokens: int = 0
|
|
41
|
+
cached_tokens: int = 0
|
|
42
|
+
request_count: int = 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class VerbCostResult:
|
|
47
|
+
"""Aggregated cost attribution for one verb invocation."""
|
|
48
|
+
|
|
49
|
+
verb: str
|
|
50
|
+
total_cost_micros: int = 0
|
|
51
|
+
input_tokens: int = 0
|
|
52
|
+
output_tokens: int = 0
|
|
53
|
+
cached_tokens: int = 0
|
|
54
|
+
request_count: int = 0
|
|
55
|
+
duration_ms: float = 0.0
|
|
56
|
+
estimated: bool = True
|
|
57
|
+
per_proxy: list[ProxyCostDelta] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fetch_snapshot(base_url: str, timeout: float = 2.0) -> dict[str, Any] | None:
|
|
61
|
+
"""Fetch proxy metrics via GET /. Returns None on failure."""
|
|
62
|
+
try:
|
|
63
|
+
normalized = base_url if "://" in base_url else f"http://{base_url}"
|
|
64
|
+
url = normalized.rstrip("/") + "/"
|
|
65
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
|
66
|
+
data = json.loads(resp.read())
|
|
67
|
+
if data.get("is_proxy") and "metrics" in data:
|
|
68
|
+
return data["metrics"]
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.debug("Failed to fetch proxy snapshot from %s: %s", base_url, e)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _compute_delta(before: dict[str, Any], after: dict[str, Any], base_url: str) -> ProxyCostDelta:
|
|
75
|
+
"""Compute the difference between two proxy metric snapshots."""
|
|
76
|
+
b_tokens = before.get("tokens", {})
|
|
77
|
+
a_tokens = after.get("tokens", {})
|
|
78
|
+
b_costs = before.get("costs", {})
|
|
79
|
+
a_costs = after.get("costs", {})
|
|
80
|
+
|
|
81
|
+
return ProxyCostDelta(
|
|
82
|
+
base_url=base_url,
|
|
83
|
+
cost_micros=a_costs.get("total_micros", 0) - b_costs.get("total_micros", 0),
|
|
84
|
+
input_tokens=a_tokens.get("input", 0) - b_tokens.get("input", 0),
|
|
85
|
+
output_tokens=a_tokens.get("output", 0) - b_tokens.get("output", 0),
|
|
86
|
+
cached_tokens=a_tokens.get("cached", 0) - b_tokens.get("cached", 0),
|
|
87
|
+
request_count=after.get("total_requests", 0) - before.get("total_requests", 0),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _verb_log_dir() -> Path:
|
|
92
|
+
return get_forge_home() / "costs" / "verbs"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _log_verb_cost(result: VerbCostResult) -> None:
|
|
96
|
+
"""Append a verb cost record to the PID-sharded JSONL log."""
|
|
97
|
+
record: dict[str, Any] = {
|
|
98
|
+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
99
|
+
"verb": result.verb,
|
|
100
|
+
"total_cost_micros": result.total_cost_micros,
|
|
101
|
+
"estimated": result.estimated,
|
|
102
|
+
"input_tokens": result.input_tokens,
|
|
103
|
+
"output_tokens": result.output_tokens,
|
|
104
|
+
"cached_tokens": result.cached_tokens,
|
|
105
|
+
"request_count": result.request_count,
|
|
106
|
+
"duration_ms": round(result.duration_ms, 1),
|
|
107
|
+
"per_proxy": [asdict(p) for p in result.per_proxy],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
from forge.core.state import open_secure_append
|
|
112
|
+
|
|
113
|
+
log_dir = _verb_log_dir()
|
|
114
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
month = datetime.now(timezone.utc).strftime("%Y-%m")
|
|
116
|
+
path = log_dir / f"{month}_{os.getpid()}.jsonl"
|
|
117
|
+
|
|
118
|
+
with _verb_lock:
|
|
119
|
+
with open_secure_append(path) as f:
|
|
120
|
+
f.write(json.dumps(record, separators=(",", ":")) + "\n")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.warning("Failed to write verb cost log: %s", e)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_subprocess_proxy_url() -> str | None:
|
|
126
|
+
"""Resolve the current FORGE_SUBPROCESS_PROXY to a base URL, if configured."""
|
|
127
|
+
from forge.core.reactive.env import (
|
|
128
|
+
FORGE_SUBPROCESS_BASE_URL_VAR,
|
|
129
|
+
FORGE_SUBPROCESS_PROXY_VAR,
|
|
130
|
+
)
|
|
131
|
+
from forge.core.reactive.proxy import lookup_proxy_base_url
|
|
132
|
+
|
|
133
|
+
injected_url = os.environ.get(FORGE_SUBPROCESS_BASE_URL_VAR)
|
|
134
|
+
if injected_url:
|
|
135
|
+
return injected_url
|
|
136
|
+
|
|
137
|
+
proxy = os.environ.get(FORGE_SUBPROCESS_PROXY_VAR)
|
|
138
|
+
if not proxy:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return lookup_proxy_base_url(proxy)
|
|
143
|
+
except Exception:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve_proxy_urls(specs: list[Any]) -> list[str]:
|
|
148
|
+
"""Extract unique proxy base URLs from a list of ModelSpecs.
|
|
149
|
+
|
|
150
|
+
For specs with no explicit proxy, falls back to FORGE_SUBPROCESS_PROXY
|
|
151
|
+
when configured.
|
|
152
|
+
Deduplicates by resolved URL.
|
|
153
|
+
"""
|
|
154
|
+
from forge.core.reactive.env import (
|
|
155
|
+
FORGE_SUBPROCESS_BASE_URL_VAR,
|
|
156
|
+
FORGE_SUBPROCESS_PROXY_VAR,
|
|
157
|
+
)
|
|
158
|
+
from forge.core.reactive.proxy import lookup_proxy_base_url
|
|
159
|
+
|
|
160
|
+
subprocess_proxy = os.environ.get(FORGE_SUBPROCESS_PROXY_VAR)
|
|
161
|
+
subprocess_base_url = os.environ.get(FORGE_SUBPROCESS_BASE_URL_VAR)
|
|
162
|
+
seen: set[str] = set()
|
|
163
|
+
urls: list[str] = []
|
|
164
|
+
for spec in specs:
|
|
165
|
+
proxy = getattr(spec, "preferred_proxy", None) or getattr(spec, "proxy", None) or subprocess_proxy
|
|
166
|
+
if not proxy:
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
url: str | None
|
|
170
|
+
if subprocess_base_url and proxy == subprocess_proxy:
|
|
171
|
+
url = subprocess_base_url
|
|
172
|
+
else:
|
|
173
|
+
url = lookup_proxy_base_url(proxy)
|
|
174
|
+
if url and url not in seen:
|
|
175
|
+
seen.add(url)
|
|
176
|
+
urls.append(url)
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
return urls
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def resolve_proxy_urls_from_plan(plan: Any) -> list[str]:
|
|
183
|
+
"""Extract unique proxy base URLs from a WorkerRoutingPlan.
|
|
184
|
+
|
|
185
|
+
Uses actual routing decisions (correct for --proxy, subprocess proxy,
|
|
186
|
+
route scan, and session proxy fallback).
|
|
187
|
+
"""
|
|
188
|
+
seen: set[str] = set()
|
|
189
|
+
urls: list[str] = []
|
|
190
|
+
for result in plan.routes:
|
|
191
|
+
url = result.base_url
|
|
192
|
+
if url and url not in seen:
|
|
193
|
+
seen.add(url)
|
|
194
|
+
urls.append(url)
|
|
195
|
+
return urls
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@contextmanager
|
|
199
|
+
def track_verb_cost(verb: str, proxy_base_urls: list[str]):
|
|
200
|
+
"""Snapshot proxy metrics across all proxies before/after a verb invocation.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
verb: Origin label ("panel", "supervisor", "handoff", etc.)
|
|
204
|
+
proxy_base_urls: ALL proxy base URLs this verb will use.
|
|
205
|
+
Direct workers (no proxy) are excluded — only proxied
|
|
206
|
+
requests have cost data at the proxy level.
|
|
207
|
+
|
|
208
|
+
Yields control to the caller. On exit, computes snapshot deltas,
|
|
209
|
+
logs the verb cost record, and discards. The caller does not
|
|
210
|
+
receive the result (it's fire-and-forget for the log).
|
|
211
|
+
"""
|
|
212
|
+
unique_urls = list(dict.fromkeys(u for u in proxy_base_urls if u))
|
|
213
|
+
|
|
214
|
+
if not unique_urls:
|
|
215
|
+
yield
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
snapshots_before: dict[str, dict[str, Any]] = {}
|
|
219
|
+
for url in unique_urls:
|
|
220
|
+
snap = _fetch_snapshot(url)
|
|
221
|
+
if snap is not None:
|
|
222
|
+
snapshots_before[url] = snap
|
|
223
|
+
|
|
224
|
+
start = time.monotonic()
|
|
225
|
+
try:
|
|
226
|
+
yield
|
|
227
|
+
finally:
|
|
228
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
deltas: list[ProxyCostDelta] = []
|
|
232
|
+
for url in unique_urls:
|
|
233
|
+
if url not in snapshots_before:
|
|
234
|
+
continue
|
|
235
|
+
after = _fetch_snapshot(url)
|
|
236
|
+
if after is None:
|
|
237
|
+
continue
|
|
238
|
+
deltas.append(_compute_delta(snapshots_before[url], after, url))
|
|
239
|
+
|
|
240
|
+
total_cost = sum(d.cost_micros for d in deltas)
|
|
241
|
+
total_input = sum(d.input_tokens for d in deltas)
|
|
242
|
+
total_output = sum(d.output_tokens for d in deltas)
|
|
243
|
+
total_cached = sum(d.cached_tokens for d in deltas)
|
|
244
|
+
total_requests = sum(d.request_count for d in deltas)
|
|
245
|
+
|
|
246
|
+
result = VerbCostResult(
|
|
247
|
+
verb=verb,
|
|
248
|
+
total_cost_micros=total_cost,
|
|
249
|
+
input_tokens=total_input,
|
|
250
|
+
output_tokens=total_output,
|
|
251
|
+
cached_tokens=total_cached,
|
|
252
|
+
request_count=total_requests,
|
|
253
|
+
duration_ms=elapsed,
|
|
254
|
+
estimated=True,
|
|
255
|
+
per_proxy=deltas,
|
|
256
|
+
)
|
|
257
|
+
_log_verb_cost(result)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.warning("Failed to track verb cost for %s: %s", verb, e)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def read_verb_logs(
|
|
263
|
+
period_start: datetime | None = None,
|
|
264
|
+
period_end: datetime | None = None,
|
|
265
|
+
) -> list[dict[str, Any]]:
|
|
266
|
+
"""Read and aggregate verb cost records from all PID shards."""
|
|
267
|
+
log_dir = _verb_log_dir()
|
|
268
|
+
if not log_dir.is_dir():
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
records: list[dict[str, Any]] = []
|
|
272
|
+
for path in sorted(log_dir.glob("*.jsonl")):
|
|
273
|
+
try:
|
|
274
|
+
with open(path) as f:
|
|
275
|
+
for line in f:
|
|
276
|
+
line = line.strip()
|
|
277
|
+
if not line:
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
record = json.loads(line)
|
|
281
|
+
except json.JSONDecodeError:
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
if period_start or period_end:
|
|
285
|
+
ts_str = record.get("ts", "")
|
|
286
|
+
try:
|
|
287
|
+
ts = datetime.fromisoformat(ts_str.rstrip("Z").removesuffix("+00:00") + "+00:00")
|
|
288
|
+
except (ValueError, TypeError):
|
|
289
|
+
continue
|
|
290
|
+
if period_start and ts < period_start:
|
|
291
|
+
continue
|
|
292
|
+
if period_end and ts >= period_end:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
records.append(record)
|
|
296
|
+
except OSError as e:
|
|
297
|
+
logger.warning("Failed to read verb log %s: %s", path, e)
|
|
298
|
+
|
|
299
|
+
records.sort(key=lambda r: r.get("ts", ""))
|
|
300
|
+
return records
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Environment builder for Claude subprocess invocation.
|
|
2
|
+
|
|
3
|
+
Provides ``build_claude_env()`` for constructing subprocess environments,
|
|
4
|
+
and ``FORGE_DEPTH`` helpers for recursion-guarding hook → subprocess chains.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Defense-in-depth: --bare prevents hook recursion in child processes,
|
|
16
|
+
# but FORGE_DEPTH still guards against subprocess spawning at depth >= 2.
|
|
17
|
+
FORGE_DEPTH_VAR = "FORGE_DEPTH"
|
|
18
|
+
FORGE_MAX_DEPTH = 2
|
|
19
|
+
|
|
20
|
+
FORGE_SUBPROCESS_PROXY_VAR = "FORGE_SUBPROCESS_PROXY"
|
|
21
|
+
FORGE_SUBPROCESS_BASE_URL_VAR = "FORGE_SUBPROCESS_BASE_URL"
|
|
22
|
+
FORGE_SUBPROCESS_PROXY_ID_VAR = "FORGE_SUBPROCESS_PROXY_ID"
|
|
23
|
+
FORGE_SUBPROCESS_TEMPLATE_VAR = "FORGE_SUBPROCESS_TEMPLATE"
|
|
24
|
+
FORGE_SIDECAR_VAR = "FORGE_SIDECAR"
|
|
25
|
+
FORGE_LAUNCH_MODE_VAR = "FORGE_LAUNCH_MODE"
|
|
26
|
+
|
|
27
|
+
# --bare (Claude Code >= 2.1.81) disables OAuth/keychain auth, requiring
|
|
28
|
+
# ANTHROPIC_API_KEY in the environment. Only safe when the key is present.
|
|
29
|
+
_BARE_AUTH_KEY = "ANTHROPIC_API_KEY"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def can_use_bare(env: Mapping[str, str] | None = None) -> bool:
|
|
33
|
+
"""True if ``--bare`` is safe for headless subprocesses.
|
|
34
|
+
|
|
35
|
+
``--bare`` disables OAuth/keychain auth, so it requires
|
|
36
|
+
ANTHROPIC_API_KEY. When an explicit ``env`` dict is given, checks
|
|
37
|
+
only that dict (caller owns the env). When using os.environ
|
|
38
|
+
(default), also falls back to the credential file via
|
|
39
|
+
``resolve_env_or_credential`` (which respects ``auth_ignore_env``).
|
|
40
|
+
"""
|
|
41
|
+
if env is not None:
|
|
42
|
+
return bool(env.get(_BARE_AUTH_KEY))
|
|
43
|
+
|
|
44
|
+
from forge.core.auth.template_secrets import resolve_env_or_credential
|
|
45
|
+
|
|
46
|
+
return bool(resolve_env_or_credential(_BARE_AUTH_KEY))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_forge_depth(env: Mapping[str, str] | None = None) -> int:
|
|
50
|
+
"""Read current FORGE_DEPTH from the given env (or os.environ).
|
|
51
|
+
|
|
52
|
+
Invalid or missing values are treated as 0 (fail-open).
|
|
53
|
+
"""
|
|
54
|
+
source = env if env is not None else os.environ
|
|
55
|
+
raw = source.get(FORGE_DEPTH_VAR, "0")
|
|
56
|
+
try:
|
|
57
|
+
return max(0, int(raw))
|
|
58
|
+
except (ValueError, TypeError):
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def should_spawn_subprocesses(env: Mapping[str, str] | None = None) -> bool:
|
|
63
|
+
"""True if current depth allows spawning ``claude -p`` subprocesses.
|
|
64
|
+
|
|
65
|
+
Returns False when depth >= FORGE_MAX_DEPTH, meaning hooks should skip
|
|
66
|
+
subprocess-spawning work (supervisor, handoff agent, etc.) to prevent
|
|
67
|
+
runaway recursion.
|
|
68
|
+
"""
|
|
69
|
+
return get_forge_depth(env) < FORGE_MAX_DEPTH
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_claude_env(
|
|
73
|
+
base_url: str | None = None,
|
|
74
|
+
extra_vars: dict[str, str] | None = None,
|
|
75
|
+
direct: bool = False,
|
|
76
|
+
) -> dict[str, str]:
|
|
77
|
+
"""Build environment dict for a Claude subprocess.
|
|
78
|
+
|
|
79
|
+
Starts with the current process environment. Sets ANTHROPIC_BASE_URL
|
|
80
|
+
if ``base_url`` is provided. When ``direct`` is True, removes any
|
|
81
|
+
inherited ANTHROPIC_BASE_URL and subprocess proxy so the child hits
|
|
82
|
+
Anthropic directly.
|
|
83
|
+
Applies ``extra_vars`` before routing and depth handling so explicit
|
|
84
|
+
function arguments remain authoritative.
|
|
85
|
+
|
|
86
|
+
Hydrates ANTHROPIC_API_KEY from the credential file when it's not in
|
|
87
|
+
the env (or when ``auth_ignore_env`` overrides it). This ensures
|
|
88
|
+
``can_use_bare(env)`` and the subprocess both see the resolved key.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
base_url: Proxy URL to route Claude requests through.
|
|
92
|
+
extra_vars: Additional environment variables to set/override.
|
|
93
|
+
direct: Force direct Anthropic routing (unset inherited proxy URL).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Complete environment dict ready for ``subprocess.run(env=...)``.
|
|
97
|
+
"""
|
|
98
|
+
env = os.environ.copy()
|
|
99
|
+
_hydrate_credentials(env)
|
|
100
|
+
|
|
101
|
+
# Apply extra_vars AFTER hydration so explicit caller overrides
|
|
102
|
+
# take precedence over credential-file values.
|
|
103
|
+
if extra_vars:
|
|
104
|
+
env.update(extra_vars)
|
|
105
|
+
|
|
106
|
+
if base_url:
|
|
107
|
+
env["ANTHROPIC_BASE_URL"] = base_url
|
|
108
|
+
elif direct:
|
|
109
|
+
env.pop("ANTHROPIC_BASE_URL", None)
|
|
110
|
+
env.pop(FORGE_SUBPROCESS_PROXY_VAR, None)
|
|
111
|
+
env.pop(FORGE_SUBPROCESS_BASE_URL_VAR, None)
|
|
112
|
+
env.pop(FORGE_SUBPROCESS_PROXY_ID_VAR, None)
|
|
113
|
+
env.pop(FORGE_SUBPROCESS_TEMPLATE_VAR, None)
|
|
114
|
+
else:
|
|
115
|
+
# No explicit base_url and not forced direct: check subprocess proxy fallback.
|
|
116
|
+
# FORGE_SUBPROCESS_PROXY is set by `forge session start --subprocess-proxy`
|
|
117
|
+
# and inherited by all child processes.
|
|
118
|
+
injected_subprocess_base_url = env.get(FORGE_SUBPROCESS_BASE_URL_VAR)
|
|
119
|
+
if injected_subprocess_base_url:
|
|
120
|
+
env["ANTHROPIC_BASE_URL"] = injected_subprocess_base_url
|
|
121
|
+
elif subprocess_proxy := env.get(FORGE_SUBPROCESS_PROXY_VAR):
|
|
122
|
+
resolved = _resolve_subprocess_proxy(subprocess_proxy)
|
|
123
|
+
if resolved:
|
|
124
|
+
env["ANTHROPIC_BASE_URL"] = resolved
|
|
125
|
+
else:
|
|
126
|
+
env.pop("ANTHROPIC_BASE_URL", None)
|
|
127
|
+
|
|
128
|
+
# Increment FORGE_DEPTH so child subprocesses know their nesting level
|
|
129
|
+
current_depth = get_forge_depth(env)
|
|
130
|
+
env[FORGE_DEPTH_VAR] = str(current_depth + 1)
|
|
131
|
+
|
|
132
|
+
return env
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _hydrate_credentials(env: dict[str, str]) -> None:
|
|
136
|
+
"""Ensure resolved credentials are in the subprocess env dict.
|
|
137
|
+
|
|
138
|
+
When ``auth_ignore_env`` is active, removes the inherited env value
|
|
139
|
+
for ANTHROPIC_API_KEY and injects the credential-file value instead.
|
|
140
|
+
When inactive, injects the credential-file value only if the env
|
|
141
|
+
var is absent (so ``can_use_bare(env)`` and the subprocess agree).
|
|
142
|
+
"""
|
|
143
|
+
from forge.core.auth.template_secrets import resolve_env_or_credential
|
|
144
|
+
|
|
145
|
+
resolved = resolve_env_or_credential(_BARE_AUTH_KEY)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
from forge.runtime_config import get_runtime_config
|
|
149
|
+
|
|
150
|
+
ignore_env = get_runtime_config().auth_ignore_env
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.debug("Could not read auth_ignore_env; using environment credentials: %s", e)
|
|
153
|
+
ignore_env = False
|
|
154
|
+
|
|
155
|
+
if ignore_env:
|
|
156
|
+
if resolved:
|
|
157
|
+
env[_BARE_AUTH_KEY] = resolved
|
|
158
|
+
else:
|
|
159
|
+
env.pop(_BARE_AUTH_KEY, None)
|
|
160
|
+
elif resolved and not env.get(_BARE_AUTH_KEY):
|
|
161
|
+
env[_BARE_AUTH_KEY] = resolved
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve_subprocess_proxy(proxy_id: str) -> str | None:
|
|
165
|
+
"""Resolve subprocess proxy to a base URL, or None if unavailable.
|
|
166
|
+
|
|
167
|
+
Direct URL lookup only (not resolve_subprocess_routing). build_claude_env()
|
|
168
|
+
sets env vars for child processes and only needs a URL. Model compatibility
|
|
169
|
+
validation happens at workflow routing time (resolve_invocation_routing).
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
from forge.core.reactive.proxy import lookup_proxy_base_url
|
|
173
|
+
|
|
174
|
+
url = lookup_proxy_base_url(proxy_id)
|
|
175
|
+
if url:
|
|
176
|
+
logger.debug("Subprocess proxy %r resolved to %s", proxy_id, url)
|
|
177
|
+
return url
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.warning("Subprocess proxy %r unavailable: %s", proxy_id, e)
|
|
180
|
+
return None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Proxy registry lookup utility.
|
|
2
|
+
|
|
3
|
+
Provides a shared function for looking up proxy base URLs from the
|
|
4
|
+
registry. Used by the semantic supervisor, handoff agent, and review engine.
|
|
5
|
+
|
|
6
|
+
Note: This module is intentionally NOT re-exported from __init__.py
|
|
7
|
+
because it lazy-imports forge.proxy.proxies (a top-level component).
|
|
8
|
+
Consumers import directly: ``from forge.core.reactive.proxy import lookup_proxy_base_url``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def lookup_proxy_base_url(proxy: str | None) -> str | None:
|
|
15
|
+
"""Look up base_url from the proxy registry by proxy_id or template name.
|
|
16
|
+
|
|
17
|
+
Internal boundary: if ``proxy`` is provided but resolution fails, the
|
|
18
|
+
exception propagates (ProxyResolutionError, AmbiguousProxyError, or
|
|
19
|
+
ProxyRegistryCorruptedError). Callers decide how to handle the failure.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
proxy: Proxy identifier or template name to look up. If None, returns None.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The proxy's base_url if found, None if proxy is None/empty.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ProxyResolutionError: If proxy name is provided but cannot be resolved.
|
|
29
|
+
ProxyRegistryCorruptedError: If the registry file is unreadable.
|
|
30
|
+
"""
|
|
31
|
+
if not proxy:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy
|
|
35
|
+
|
|
36
|
+
registry = ProxyRegistryStore().read()
|
|
37
|
+
entry = resolve_proxy(registry, proxy)
|
|
38
|
+
return entry.base_url
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_proxy_reachable(
|
|
42
|
+
proxy: str,
|
|
43
|
+
timeout_s: float = 1.0,
|
|
44
|
+
) -> tuple[bool, str, str | None]:
|
|
45
|
+
"""Check if a named proxy is locally routable.
|
|
46
|
+
|
|
47
|
+
Resolves via registry, then HTTP health-checks the endpoint.
|
|
48
|
+
"Ready" means the proxy responds at its base_url with valid
|
|
49
|
+
proxy metadata -- not just that it exists in the registry.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
(reachable, reason, base_url):
|
|
53
|
+
- reachable: True if proxy resolves AND health check passes
|
|
54
|
+
- reason: empty if reachable, human-readable otherwise
|
|
55
|
+
- base_url: proxy URL if resolved, None otherwise
|
|
56
|
+
"""
|
|
57
|
+
from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy
|
|
58
|
+
from forge.proxy.proxy_orchestrator import check_proxy_health
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
registry = ProxyRegistryStore().read()
|
|
62
|
+
except Exception as e:
|
|
63
|
+
return (False, f"Registry error: {e}", None)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
entry = resolve_proxy(registry, proxy)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
return (False, str(e), None)
|
|
69
|
+
|
|
70
|
+
if not check_proxy_health(
|
|
71
|
+
base_url=entry.base_url,
|
|
72
|
+
expected_template=entry.template,
|
|
73
|
+
expected_proxy_id=entry.proxy_id,
|
|
74
|
+
timeout_s=timeout_s,
|
|
75
|
+
):
|
|
76
|
+
return (False, f"Proxy '{proxy}' not responding at {entry.base_url}", entry.base_url)
|
|
77
|
+
|
|
78
|
+
return (True, "", entry.base_url)
|