devcouncil 0.1.1 → 0.3.0
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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -1,34 +1,162 @@
|
|
|
1
1
|
from abc import ABC, abstractmethod
|
|
2
2
|
import copy
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from importlib import resources
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
3
7
|
from typing import List, Dict, Any, Optional
|
|
4
|
-
from pydantic import BaseModel
|
|
8
|
+
from pydantic import BaseModel, field_validator
|
|
5
9
|
import httpx
|
|
6
10
|
import json
|
|
7
11
|
from pathlib import Path
|
|
12
|
+
import yaml
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
SUPPORTED_MODEL_PROVIDERS = ("openrouter", "vertexai", "doubleword", "ollama")
|
|
17
|
+
PROVIDER_ALIASES = {
|
|
18
|
+
"vertex-ai": "vertexai",
|
|
19
|
+
"vertex_ai": "vertexai",
|
|
20
|
+
"ollama-local": "ollama",
|
|
21
|
+
"ollama_local": "ollama",
|
|
22
|
+
}
|
|
23
|
+
MODEL_DEFAULTS_RESOURCE = "model_defaults.yaml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache(maxsize=1)
|
|
27
|
+
def load_default_role_models_by_provider() -> Dict[str, Dict[str, str]]:
|
|
28
|
+
data = resources.files(__package__).joinpath(MODEL_DEFAULTS_RESOURCE).read_text(encoding="utf-8")
|
|
29
|
+
loaded = yaml.safe_load(data) or {}
|
|
30
|
+
return {
|
|
31
|
+
str(provider): {str(role): str(model) for role, model in roles.items()}
|
|
32
|
+
for provider, roles in loaded.items()
|
|
33
|
+
if isinstance(roles, dict)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_ROLE_MODELS_BY_PROVIDER = load_default_role_models_by_provider()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProviderRequestError(RuntimeError):
|
|
41
|
+
"""A provider HTTP request failed, with an actionable, user-facing message."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status_code = status_code
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def raise_for_provider_status(response: "httpx.Response", provider: str) -> None:
|
|
49
|
+
"""Translate an HTTP error response into an actionable ProviderRequestError.
|
|
50
|
+
|
|
51
|
+
The raw ``httpx.HTTPStatusError`` surfaces as an unhelpful traceback; common
|
|
52
|
+
statuses (auth, billing, rate limiting) have concrete remedies worth naming.
|
|
53
|
+
"""
|
|
54
|
+
status = getattr(response, "status_code", None)
|
|
55
|
+
if status is None or status < 400:
|
|
56
|
+
return
|
|
57
|
+
hints = {
|
|
58
|
+
401: "authentication failed — check the API key in .devcouncil/secrets.env",
|
|
59
|
+
402: "payment required — the account is out of credits or has no active balance; add funds and retry",
|
|
60
|
+
403: "access forbidden — the API key may lack access to the requested model",
|
|
61
|
+
404: "not found — check the configured model id and provider base URL",
|
|
62
|
+
429: "rate limited — too many requests; wait a moment and retry",
|
|
63
|
+
}
|
|
64
|
+
detail = hints.get(status, "the request was rejected")
|
|
65
|
+
body = ""
|
|
66
|
+
text = getattr(response, "text", None)
|
|
67
|
+
if isinstance(text, str):
|
|
68
|
+
body = text.strip()[:300]
|
|
69
|
+
message = f"{provider} API error {status}: {detail}."
|
|
70
|
+
if body:
|
|
71
|
+
message = f"{message} Response: {body}"
|
|
72
|
+
logger.error("Provider request failed: %s", message)
|
|
73
|
+
raise ProviderRequestError(message, status_code=status)
|
|
10
74
|
|
|
11
75
|
|
|
12
76
|
class LLMResponse(BaseModel):
|
|
13
77
|
content: str
|
|
14
78
|
model: str
|
|
15
|
-
usage
|
|
79
|
+
# OpenRouter (and other providers) return richer usage payloads than plain
|
|
80
|
+
# token counts: a float ``cost`` plus nested ``*_details`` dicts. Keep this
|
|
81
|
+
# permissive so live responses parse; downstream only reads the int token keys.
|
|
82
|
+
usage: Dict[str, Any]
|
|
16
83
|
raw_response: Dict[str, Any]
|
|
17
84
|
|
|
85
|
+
@field_validator("content", mode="before")
|
|
86
|
+
@classmethod
|
|
87
|
+
def _coerce_null_content(cls, value: Any) -> str:
|
|
88
|
+
# Providers return ``content: null`` for reasoning-only, tool-only, or
|
|
89
|
+
# filtered responses. Treat that as empty text so the router's parse /
|
|
90
|
+
# healing path can retry instead of crashing on a validation error.
|
|
91
|
+
return value if value is not None else ""
|
|
92
|
+
|
|
18
93
|
class Provider(ABC):
|
|
19
94
|
@abstractmethod
|
|
20
95
|
async def complete(
|
|
21
|
-
self,
|
|
22
|
-
model: str,
|
|
23
|
-
messages: List[Dict[str, str]],
|
|
96
|
+
self,
|
|
97
|
+
model: str,
|
|
98
|
+
messages: List[Dict[str, str]],
|
|
24
99
|
temperature: float = 0.0,
|
|
25
|
-
json_mode: bool = False
|
|
100
|
+
json_mode: bool = False,
|
|
101
|
+
task_id: Optional[str] = None,
|
|
102
|
+
run_id: Optional[str] = None,
|
|
26
103
|
) -> LLMResponse:
|
|
27
104
|
pass
|
|
28
105
|
|
|
106
|
+
def _get_async_client(self, timeout: Any) -> "httpx.AsyncClient":
|
|
107
|
+
"""Lazily create and reuse a single ``httpx.AsyncClient`` per provider instance.
|
|
108
|
+
|
|
109
|
+
Building an ``AsyncClient`` (connection pool + SSL context) is expensive and the
|
|
110
|
+
pool is meant to be reused across calls, so we keep one per instance rather than
|
|
111
|
+
constructing a fresh client on every ``complete()``. ``timeout`` is fixed per
|
|
112
|
+
provider instance (cloud providers use 180s; Ollama uses its resolved
|
|
113
|
+
``self.timeout``), so binding it at construction time is equivalent to the previous
|
|
114
|
+
per-call client while still allowing the pool to be reused.
|
|
115
|
+
|
|
116
|
+
The client is bound to the event loop that created it. If the same provider
|
|
117
|
+
instance is ever driven from a *different* loop (e.g. a second ``asyncio.run``),
|
|
118
|
+
the old client's pool belongs to a now-closed loop and cannot be reused — we
|
|
119
|
+
detect that and rebind a fresh client to the current loop instead of failing. The
|
|
120
|
+
client lives for the provider's lifetime (one run / one cached router) and is
|
|
121
|
+
released on GC or via ``aclose()``; provider instances are bounded, so clients do
|
|
122
|
+
not accumulate."""
|
|
123
|
+
import asyncio
|
|
124
|
+
|
|
125
|
+
loop = asyncio.get_running_loop()
|
|
126
|
+
client = getattr(self, "_client", None)
|
|
127
|
+
if client is not None and not client.is_closed and getattr(self, "_client_loop", None) is loop:
|
|
128
|
+
return client
|
|
129
|
+
client = httpx.AsyncClient(timeout=timeout)
|
|
130
|
+
self._client: Optional[httpx.AsyncClient] = client
|
|
131
|
+
self._client_loop = loop
|
|
132
|
+
return client
|
|
133
|
+
|
|
134
|
+
async def aclose(self) -> None:
|
|
135
|
+
"""Close the reused AsyncClient if one was created."""
|
|
136
|
+
client = getattr(self, "_client", None)
|
|
137
|
+
if client is not None:
|
|
138
|
+
self._client = None
|
|
139
|
+
await client.aclose()
|
|
140
|
+
|
|
141
|
+
def cache_fingerprint(self) -> str:
|
|
142
|
+
"""Provider-specific options that change the model's output and therefore must
|
|
143
|
+
be part of the LLM cache key. Empty for providers whose output depends only on
|
|
144
|
+
``(model, messages, temperature, json_mode)``; overridden where a runtime knob
|
|
145
|
+
(e.g. Ollama's ``num_ctx`` / base URL) silently alters results for an identical
|
|
146
|
+
prompt."""
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
def is_local_cost_free(self) -> bool:
|
|
150
|
+
"""True for on-device providers that incur no per-token cost (Ollama). Lets the
|
|
151
|
+
telemetry tracker zero local usage by PROVIDER rather than by model-id matching —
|
|
152
|
+
local model tags are open-ended (``qwen2.5-coder:7b``) and may collide with priced
|
|
153
|
+
entries. Mirrors the provider-based zeroing in ``telemetry/cost.py``."""
|
|
154
|
+
return False
|
|
155
|
+
|
|
29
156
|
|
|
30
157
|
def validate_model_provider(provider_name: str) -> str:
|
|
31
158
|
normalized = provider_name.strip().lower()
|
|
159
|
+
normalized = PROVIDER_ALIASES.get(normalized, normalized)
|
|
32
160
|
if normalized in SUPPORTED_MODEL_PROVIDERS:
|
|
33
161
|
return normalized
|
|
34
162
|
supported = ", ".join(SUPPORTED_MODEL_PROVIDERS)
|
|
@@ -38,26 +166,173 @@ def validate_model_provider(provider_name: str) -> str:
|
|
|
38
166
|
)
|
|
39
167
|
|
|
40
168
|
|
|
41
|
-
def
|
|
169
|
+
def apply_provider_default_role_models(
|
|
170
|
+
raw_config: Dict[str, Any],
|
|
171
|
+
previous_provider: str,
|
|
172
|
+
new_provider: str,
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""Update role defaults when switching providers without overwriting custom models."""
|
|
175
|
+
new = validate_model_provider(new_provider)
|
|
176
|
+
models = raw_config.setdefault("models", {})
|
|
177
|
+
roles = models.setdefault("roles", {})
|
|
178
|
+
try:
|
|
179
|
+
previous = validate_model_provider(previous_provider)
|
|
180
|
+
previous_defaults = DEFAULT_ROLE_MODELS_BY_PROVIDER[previous]
|
|
181
|
+
except ValueError:
|
|
182
|
+
previous_defaults = {}
|
|
183
|
+
new_defaults = DEFAULT_ROLE_MODELS_BY_PROVIDER[new]
|
|
184
|
+
changed = False
|
|
185
|
+
|
|
186
|
+
for role, new_model in new_defaults.items():
|
|
187
|
+
role_config = roles.setdefault(role, {})
|
|
188
|
+
current_model = role_config.get("model")
|
|
189
|
+
if current_model is None or current_model == previous_defaults.get(role):
|
|
190
|
+
if current_model != new_model:
|
|
191
|
+
role_config["model"] = new_model
|
|
192
|
+
changed = True
|
|
193
|
+
|
|
194
|
+
return changed
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def build_role_model_config(
|
|
198
|
+
provider: str = "openrouter",
|
|
199
|
+
model: str | None = None,
|
|
200
|
+
role_models: Dict[str, str] | None = None,
|
|
201
|
+
) -> Dict[str, Dict[str, str]]:
|
|
202
|
+
"""Build config-ready model role mappings for a provider.
|
|
203
|
+
|
|
204
|
+
If ``model`` is supplied, it is used for every known role. Per-role entries
|
|
205
|
+
in ``role_models`` override both provider defaults and the shared model.
|
|
206
|
+
"""
|
|
207
|
+
normalized = validate_model_provider(provider)
|
|
208
|
+
roles = {
|
|
209
|
+
role: {"model": selected_model}
|
|
210
|
+
for role, selected_model in DEFAULT_ROLE_MODELS_BY_PROVIDER[normalized].items()
|
|
211
|
+
}
|
|
212
|
+
if model:
|
|
213
|
+
roles = {role: {"model": model} for role in roles}
|
|
214
|
+
for role, selected_model in (role_models or {}).items():
|
|
215
|
+
roles[role] = {"model": selected_model}
|
|
216
|
+
return roles
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def openrouter_provider_payload(prefs: Any) -> Optional[Dict[str, Any]]:
|
|
220
|
+
"""Translate DevCouncil's ``ProviderConfig`` (or a plain mapping) into OpenRouter's
|
|
221
|
+
``provider`` routing object.
|
|
222
|
+
|
|
223
|
+
Returns ``None`` when no prefs are supplied so the request omits the field entirely
|
|
224
|
+
and OpenRouter applies its own defaults. Only the keys OpenRouter recognizes are
|
|
225
|
+
forwarded (``sort``, ``allow_fallbacks``, ``require_parameters``, ``data_collection``),
|
|
226
|
+
so adding unrelated fields to ``ProviderConfig`` never leaks into the API call.
|
|
227
|
+
"""
|
|
228
|
+
if prefs is None:
|
|
229
|
+
return None
|
|
230
|
+
if hasattr(prefs, "model_dump"):
|
|
231
|
+
data = prefs.model_dump()
|
|
232
|
+
elif isinstance(prefs, dict):
|
|
233
|
+
data = prefs
|
|
234
|
+
else:
|
|
235
|
+
return None
|
|
236
|
+
allowed = ("sort", "allow_fallbacks", "require_parameters", "data_collection")
|
|
237
|
+
payload = {k: data[k] for k in allowed if data.get(k) is not None}
|
|
238
|
+
return payload or None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def create_provider(
|
|
242
|
+
provider_name: str,
|
|
243
|
+
api_key: str,
|
|
244
|
+
project_root: Path = Path("."),
|
|
245
|
+
provider_prefs: Any = None,
|
|
246
|
+
) -> Provider:
|
|
42
247
|
normalized = validate_model_provider(provider_name)
|
|
43
248
|
if normalized == "openrouter":
|
|
44
|
-
return OpenRouterProvider(api_key)
|
|
249
|
+
return OpenRouterProvider(api_key, project_root=project_root, provider_prefs=provider_prefs)
|
|
250
|
+
if normalized == "doubleword":
|
|
251
|
+
return DoublewordProvider(api_key, project_root=project_root)
|
|
252
|
+
if normalized == "ollama":
|
|
253
|
+
return OllamaProvider(api_key, project_root=project_root)
|
|
254
|
+
if normalized == "vertexai":
|
|
255
|
+
from devcouncil.app.config import load_local_secrets
|
|
256
|
+
local_secrets = load_local_secrets(project_root)
|
|
257
|
+
project_id = (
|
|
258
|
+
os.environ.get("VERTEXAI_PROJECT")
|
|
259
|
+
or os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
260
|
+
or local_secrets.get("VERTEXAI_PROJECT")
|
|
261
|
+
or local_secrets.get("GOOGLE_CLOUD_PROJECT")
|
|
262
|
+
)
|
|
263
|
+
location = os.environ.get("VERTEXAI_LOCATION") or local_secrets.get("VERTEXAI_LOCATION", "global")
|
|
264
|
+
return VertexAIProvider(api_key, project_id=project_id, location=location, project_root=project_root)
|
|
45
265
|
raise AssertionError(f"Provider validation passed for unhandled provider: {normalized}")
|
|
46
266
|
|
|
267
|
+
|
|
268
|
+
def _log_model_call(
|
|
269
|
+
payload: Dict[str, Any],
|
|
270
|
+
data: Dict[str, Any],
|
|
271
|
+
usage: Dict[str, int],
|
|
272
|
+
project_root: Path = Path("."),
|
|
273
|
+
task_id: Optional[str] = None,
|
|
274
|
+
run_id: Optional[str] = None,
|
|
275
|
+
provider: Optional[str] = None,
|
|
276
|
+
) -> None:
|
|
277
|
+
try:
|
|
278
|
+
from datetime import datetime, timezone
|
|
279
|
+
|
|
280
|
+
from devcouncil.utils.redaction import redact_dict
|
|
281
|
+
# Resolve against the provider's project root, not the process cwd — otherwise
|
|
282
|
+
# running `dev` from another directory logged spend to the wrong project.
|
|
283
|
+
log_dir = project_root / ".devcouncil" / "logs"
|
|
284
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
log_file = log_dir / "model_calls.jsonl"
|
|
286
|
+
|
|
287
|
+
# task_id/run_id/timestamp/provider are optional and backward-compatible: older
|
|
288
|
+
# records simply lack them and are grouped under "(unattributed)" by the cost
|
|
289
|
+
# reporter. provider lets the cost ledger zero-cost local providers (ollama)
|
|
290
|
+
# regardless of the open-ended model tag Ollama echoes back.
|
|
291
|
+
log_payload = {
|
|
292
|
+
"request": redact_dict(payload),
|
|
293
|
+
"response": redact_dict(data),
|
|
294
|
+
"usage": usage,
|
|
295
|
+
"task_id": task_id,
|
|
296
|
+
"run_id": run_id,
|
|
297
|
+
"provider": provider,
|
|
298
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
299
|
+
}
|
|
300
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
301
|
+
f.write(json.dumps(log_payload) + "\n")
|
|
302
|
+
except Exception as e:
|
|
303
|
+
import logging as _log
|
|
304
|
+
_log.getLogger(__name__).debug("Failed to log model call: %s", e)
|
|
305
|
+
|
|
306
|
+
|
|
47
307
|
class OpenRouterProvider(Provider):
|
|
48
|
-
def __init__(self, api_key: str):
|
|
308
|
+
def __init__(self, api_key: str, project_root: Path = Path("."), provider_prefs: Any = None):
|
|
49
309
|
self.api_key = api_key
|
|
50
310
|
self.base_url = "https://openrouter.ai/api/v1"
|
|
311
|
+
self.project_root = project_root
|
|
312
|
+
# OpenRouter routing preferences (sort/allow_fallbacks/require_parameters/
|
|
313
|
+
# data_collection) sent as the request's ``provider`` field. None → omit it.
|
|
314
|
+
self.provider_prefs = openrouter_provider_payload(provider_prefs)
|
|
315
|
+
|
|
316
|
+
def cache_fingerprint(self) -> str:
|
|
317
|
+
# Routing prefs change which upstream provider/model serves the request (and the
|
|
318
|
+
# data-collection policy), so they can change the output for an identical prompt
|
|
319
|
+
# and must invalidate the cache. Empty when unset so default runs share one key.
|
|
320
|
+
if not self.provider_prefs:
|
|
321
|
+
return ""
|
|
322
|
+
return "openrouter:provider=" + json.dumps(self.provider_prefs, sort_keys=True)
|
|
51
323
|
|
|
52
324
|
async def complete(
|
|
53
325
|
self,
|
|
54
326
|
model: str,
|
|
55
327
|
messages: List[Dict[str, str]],
|
|
56
328
|
temperature: float = 0.0,
|
|
57
|
-
json_mode: bool = False
|
|
329
|
+
json_mode: bool = False,
|
|
330
|
+
task_id: Optional[str] = None,
|
|
331
|
+
run_id: Optional[str] = None,
|
|
58
332
|
) -> LLMResponse:
|
|
59
|
-
#
|
|
60
|
-
|
|
333
|
+
# Only deep-copy when json_mode mutates the last message; otherwise the
|
|
334
|
+
# caller's list is read but never modified, so we can use it directly.
|
|
335
|
+
msgs = copy.deepcopy(messages) if json_mode else messages
|
|
61
336
|
|
|
62
337
|
headers = {
|
|
63
338
|
"Authorization": f"Bearer {self.api_key}",
|
|
@@ -65,55 +340,346 @@ class OpenRouterProvider(Provider):
|
|
|
65
340
|
"HTTP-Referer": "https://github.com/devcouncil/devcouncil", # Optional
|
|
66
341
|
"X-Title": "DevCouncil", # Optional
|
|
67
342
|
}
|
|
68
|
-
|
|
343
|
+
|
|
69
344
|
payload = {
|
|
70
345
|
"model": model,
|
|
71
346
|
"messages": msgs,
|
|
72
347
|
"temperature": temperature,
|
|
73
348
|
}
|
|
74
|
-
|
|
349
|
+
|
|
75
350
|
if json_mode:
|
|
76
351
|
payload["response_format"] = {"type": "json_object"}
|
|
77
352
|
# Ensure the user message mentions JSON
|
|
78
353
|
if msgs[-1]["role"] == "user":
|
|
79
354
|
msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
|
|
80
355
|
|
|
81
|
-
|
|
356
|
+
if self.provider_prefs:
|
|
357
|
+
payload["provider"] = self.provider_prefs
|
|
358
|
+
|
|
359
|
+
client = self._get_async_client(180.0)
|
|
360
|
+
response = await client.post(
|
|
361
|
+
f"{self.base_url}/chat/completions",
|
|
362
|
+
headers=headers,
|
|
363
|
+
json=payload,
|
|
364
|
+
)
|
|
365
|
+
raise_for_provider_status(response, "OpenRouter")
|
|
366
|
+
data = response.json()
|
|
367
|
+
|
|
368
|
+
resp = LLMResponse(
|
|
369
|
+
content=data["choices"][0]["message"]["content"],
|
|
370
|
+
model=data["model"],
|
|
371
|
+
usage=data.get("usage", {}),
|
|
372
|
+
raw_response=data
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
_log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
|
|
376
|
+
|
|
377
|
+
return resp
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class DoublewordProvider(Provider):
|
|
381
|
+
def __init__(self, api_key: str, project_root: Path = Path(".")):
|
|
382
|
+
self.api_key = api_key
|
|
383
|
+
self.base_url = "https://api.doubleword.ai/v1"
|
|
384
|
+
self.project_root = project_root
|
|
385
|
+
|
|
386
|
+
async def complete(
|
|
387
|
+
self,
|
|
388
|
+
model: str,
|
|
389
|
+
messages: List[Dict[str, str]],
|
|
390
|
+
temperature: float = 0.0,
|
|
391
|
+
json_mode: bool = False,
|
|
392
|
+
task_id: Optional[str] = None,
|
|
393
|
+
run_id: Optional[str] = None,
|
|
394
|
+
) -> LLMResponse:
|
|
395
|
+
# Only deep-copy when json_mode mutates the last message; otherwise the
|
|
396
|
+
# caller's list is read but never modified, so we can use it directly.
|
|
397
|
+
msgs = copy.deepcopy(messages) if json_mode else messages
|
|
398
|
+
headers = {
|
|
399
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
400
|
+
"Content-Type": "application/json",
|
|
401
|
+
}
|
|
402
|
+
payload = {
|
|
403
|
+
"model": model,
|
|
404
|
+
"messages": msgs,
|
|
405
|
+
"temperature": temperature,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if json_mode:
|
|
409
|
+
payload["response_format"] = {"type": "json_object"}
|
|
410
|
+
if msgs[-1]["role"] == "user":
|
|
411
|
+
msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
|
|
412
|
+
|
|
413
|
+
client = self._get_async_client(180.0)
|
|
414
|
+
response = await client.post(
|
|
415
|
+
f"{self.base_url}/chat/completions",
|
|
416
|
+
headers=headers,
|
|
417
|
+
json=payload,
|
|
418
|
+
)
|
|
419
|
+
raise_for_provider_status(response, "Doubleword")
|
|
420
|
+
data = response.json()
|
|
421
|
+
|
|
422
|
+
resp = LLMResponse(
|
|
423
|
+
content=data["choices"][0]["message"]["content"],
|
|
424
|
+
model=data["model"],
|
|
425
|
+
usage=data.get("usage", {}),
|
|
426
|
+
raw_response=data
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
_log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
|
|
430
|
+
return resp
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class OllamaProvider(Provider):
|
|
434
|
+
"""Local Ollama provider via its NATIVE ``/api/chat`` endpoint.
|
|
435
|
+
|
|
436
|
+
Ollama needs no API key. The base URL is overridable via ``OLLAMA_BASE_URL``
|
|
437
|
+
(taken verbatim) or Ollama's native ``OLLAMA_HOST`` (normalized: a missing scheme
|
|
438
|
+
is prefixed with ``http://`` and a missing ``/v1`` suffix is appended). The actual
|
|
439
|
+
request goes to the native ``/api/chat`` endpoint (derived by stripping a trailing
|
|
440
|
+
``/v1``) rather than the OpenAI-compatible ``/v1/chat/completions`` — because the
|
|
441
|
+
native endpoint is the only one that honors ``options.num_ctx`` (set via
|
|
442
|
+
``OLLAMA_NUM_CTX``) and ``format: json``. DevCouncil's planning prompts are large
|
|
443
|
+
(up to ~15k tokens), so without a raised ``num_ctx`` Ollama's small default context
|
|
444
|
+
would silently truncate them.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
def __init__(
|
|
448
|
+
self,
|
|
449
|
+
api_key: str = "",
|
|
450
|
+
project_root: Path = Path("."),
|
|
451
|
+
base_url: str | None = None,
|
|
452
|
+
num_ctx: int | None = None,
|
|
453
|
+
):
|
|
454
|
+
self.api_key = api_key
|
|
455
|
+
self.base_url = base_url or self._resolve_base_url()
|
|
456
|
+
self.project_root = project_root
|
|
457
|
+
self.num_ctx = num_ctx if num_ctx is not None else self._resolve_num_ctx()
|
|
458
|
+
self.timeout = self._resolve_timeout()
|
|
459
|
+
|
|
460
|
+
# Local generation latency is unbounded (cold loads, CPU-only hosts, large
|
|
461
|
+
# ``num_ctx``) and is not a network failure, so Ollama gets a generous default
|
|
462
|
+
# and an explicit override rather than the cloud providers' fixed 180s.
|
|
463
|
+
DEFAULT_TIMEOUT = 600.0
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def _resolve_timeout() -> float | None:
|
|
467
|
+
"""Read timeout from ``OLLAMA_TIMEOUT`` seconds (positive float). ``0``/``none``/
|
|
468
|
+
``off`` disables it entirely for very slow local models; unset/invalid falls back
|
|
469
|
+
to :data:`DEFAULT_TIMEOUT`."""
|
|
470
|
+
raw = os.environ.get("OLLAMA_TIMEOUT")
|
|
471
|
+
if raw is None:
|
|
472
|
+
return OllamaProvider.DEFAULT_TIMEOUT
|
|
473
|
+
raw = raw.strip().lower()
|
|
474
|
+
if raw in {"0", "none", "off", ""}:
|
|
475
|
+
return None
|
|
476
|
+
try:
|
|
477
|
+
value = float(raw)
|
|
478
|
+
except ValueError:
|
|
479
|
+
return OllamaProvider.DEFAULT_TIMEOUT
|
|
480
|
+
return value if value > 0 else None
|
|
481
|
+
|
|
482
|
+
def cache_fingerprint(self) -> str:
|
|
483
|
+
# num_ctx and the target server change the response for an identical prompt (a
|
|
484
|
+
# larger window avoids the truncation a smaller one silently applies; a different
|
|
485
|
+
# endpoint is a different model server), so both must invalidate the cache. Key on
|
|
486
|
+
# the *normalized* /api/chat endpoint, not the raw base_url, so equivalent configs
|
|
487
|
+
# (OLLAMA_HOST vs OLLAMA_BASE_URL, with/without a trailing /v1) collapse to one key.
|
|
488
|
+
return f"ollama:num_ctx={self.num_ctx};endpoint={self._chat_endpoint()}"
|
|
489
|
+
|
|
490
|
+
def is_local_cost_free(self) -> bool:
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def _resolve_base_url() -> str:
|
|
495
|
+
explicit = os.environ.get("OLLAMA_BASE_URL")
|
|
496
|
+
if explicit:
|
|
497
|
+
return explicit.rstrip("/")
|
|
498
|
+
host = os.environ.get("OLLAMA_HOST")
|
|
499
|
+
if host:
|
|
500
|
+
host = host.strip()
|
|
501
|
+
if "://" not in host:
|
|
502
|
+
host = f"http://{host}"
|
|
503
|
+
host = host.rstrip("/")
|
|
504
|
+
if not host.endswith("/v1"):
|
|
505
|
+
host = f"{host}/v1"
|
|
506
|
+
return host
|
|
507
|
+
return "http://localhost:11434/v1"
|
|
508
|
+
|
|
509
|
+
@staticmethod
|
|
510
|
+
def _resolve_num_ctx() -> int | None:
|
|
511
|
+
"""Context window from ``OLLAMA_NUM_CTX`` (positive int), else None (server default)."""
|
|
512
|
+
raw = os.environ.get("OLLAMA_NUM_CTX")
|
|
513
|
+
if not raw:
|
|
514
|
+
return None
|
|
515
|
+
try:
|
|
516
|
+
value = int(raw)
|
|
517
|
+
except (TypeError, ValueError):
|
|
518
|
+
return None
|
|
519
|
+
return value if value > 0 else None
|
|
520
|
+
|
|
521
|
+
def _chat_endpoint(self) -> str:
|
|
522
|
+
"""Native chat endpoint derived from base_url (strip a trailing ``/v1``)."""
|
|
523
|
+
root = self.base_url.rstrip("/")
|
|
524
|
+
if root.endswith("/v1"):
|
|
525
|
+
root = root[: -len("/v1")].rstrip("/")
|
|
526
|
+
return f"{root}/api/chat"
|
|
527
|
+
|
|
528
|
+
async def complete(
|
|
529
|
+
self,
|
|
530
|
+
model: str,
|
|
531
|
+
messages: List[Dict[str, str]],
|
|
532
|
+
temperature: float = 0.0,
|
|
533
|
+
json_mode: bool = False,
|
|
534
|
+
task_id: Optional[str] = None,
|
|
535
|
+
run_id: Optional[str] = None,
|
|
536
|
+
) -> LLMResponse:
|
|
537
|
+
# Only deep-copy when json_mode mutates the last message; otherwise the
|
|
538
|
+
# caller's list is read but never modified, so we can use it directly.
|
|
539
|
+
msgs = copy.deepcopy(messages) if json_mode else messages
|
|
540
|
+
headers = {
|
|
541
|
+
"Content-Type": "application/json",
|
|
542
|
+
}
|
|
543
|
+
# Ollama ignores auth, but a configured key (e.g. for a reverse proxy)
|
|
544
|
+
# passes through harmlessly.
|
|
545
|
+
if self.api_key:
|
|
546
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
547
|
+
|
|
548
|
+
# Native /api/chat options. temperature and num_ctx live under "options"; a
|
|
549
|
+
# raised num_ctx (OLLAMA_NUM_CTX) prevents silent truncation of large prompts.
|
|
550
|
+
options: Dict[str, Any] = {"temperature": temperature}
|
|
551
|
+
if self.num_ctx:
|
|
552
|
+
options["num_ctx"] = self.num_ctx
|
|
553
|
+
|
|
554
|
+
payload: Dict[str, Any] = {
|
|
555
|
+
"model": model,
|
|
556
|
+
"messages": msgs,
|
|
557
|
+
"stream": False,
|
|
558
|
+
"options": options,
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if json_mode:
|
|
562
|
+
# Native structured-output switch (more reliable than OpenAI response_format
|
|
563
|
+
# on Ollama). Still nudge the prompt so the model knows to emit JSON.
|
|
564
|
+
payload["format"] = "json"
|
|
565
|
+
if msgs[-1]["role"] == "user":
|
|
566
|
+
msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
|
|
567
|
+
|
|
568
|
+
client = self._get_async_client(self.timeout)
|
|
569
|
+
response = await client.post(
|
|
570
|
+
self._chat_endpoint(),
|
|
571
|
+
headers=headers,
|
|
572
|
+
json=payload,
|
|
573
|
+
)
|
|
574
|
+
raise_for_provider_status(response, "Ollama")
|
|
575
|
+
data = response.json()
|
|
576
|
+
|
|
577
|
+
# Native response shape: {"message": {"content": ...}, "model": ...,
|
|
578
|
+
# "prompt_eval_count": N, "eval_count": M}. Map token counts to the
|
|
579
|
+
# OpenAI-style keys the cost ledger/tracker expect.
|
|
580
|
+
prompt_tokens = int(data.get("prompt_eval_count", 0) or 0)
|
|
581
|
+
completion_tokens = int(data.get("eval_count", 0) or 0)
|
|
582
|
+
usage = {
|
|
583
|
+
"prompt_tokens": prompt_tokens,
|
|
584
|
+
"completion_tokens": completion_tokens,
|
|
585
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
586
|
+
}
|
|
587
|
+
resp = LLMResponse(
|
|
588
|
+
content=(data.get("message") or {}).get("content", ""),
|
|
589
|
+
# Ollama may omit ``model`` or return a local tag — fall back to
|
|
590
|
+
# the requested id rather than KeyError-ing.
|
|
591
|
+
model=data.get("model", model),
|
|
592
|
+
usage=usage,
|
|
593
|
+
raw_response=data,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
_log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id, provider="ollama")
|
|
597
|
+
return resp
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
class VertexAIProvider(Provider):
|
|
601
|
+
"""Vertex AI provider using Google's OpenAI-compatible Chat Completions API."""
|
|
602
|
+
|
|
603
|
+
def __init__(self, access_token: str, project_id: str | None = None, location: str | None = None, project_root: Path = Path(".")):
|
|
604
|
+
self.access_token = access_token
|
|
605
|
+
self.project_id = project_id or os.environ.get("VERTEXAI_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
606
|
+
self.location = location or os.environ.get("VERTEXAI_LOCATION", "global")
|
|
607
|
+
self.project_root = project_root
|
|
608
|
+
|
|
609
|
+
@property
|
|
610
|
+
def base_url(self) -> str:
|
|
611
|
+
if not self.project_id:
|
|
612
|
+
raise ValueError(
|
|
613
|
+
"Vertex AI project is not configured. Set VERTEXAI_PROJECT or GOOGLE_CLOUD_PROJECT."
|
|
614
|
+
)
|
|
615
|
+
return (
|
|
616
|
+
f"https://aiplatform.googleapis.com/v1/projects/{self.project_id}"
|
|
617
|
+
f"/locations/{self.location}/endpoints/openapi"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def _headers(self) -> Dict[str, str]:
|
|
621
|
+
return {
|
|
622
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
623
|
+
"Content-Type": "application/json",
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
def _refresh_access_token_from_gcloud(self) -> bool:
|
|
627
|
+
from devcouncil.app.config import get_gcloud_access_token
|
|
628
|
+
|
|
629
|
+
refreshed = get_gcloud_access_token()
|
|
630
|
+
if not refreshed:
|
|
631
|
+
return False
|
|
632
|
+
self.access_token = refreshed
|
|
633
|
+
return True
|
|
634
|
+
|
|
635
|
+
async def complete(
|
|
636
|
+
self,
|
|
637
|
+
model: str,
|
|
638
|
+
messages: List[Dict[str, str]],
|
|
639
|
+
temperature: float = 0.0,
|
|
640
|
+
json_mode: bool = False,
|
|
641
|
+
task_id: Optional[str] = None,
|
|
642
|
+
run_id: Optional[str] = None,
|
|
643
|
+
) -> LLMResponse:
|
|
644
|
+
# Only deep-copy when json_mode mutates the last message; otherwise the
|
|
645
|
+
# caller's list is read but never modified, so we can use it directly.
|
|
646
|
+
msgs = copy.deepcopy(messages) if json_mode else messages
|
|
647
|
+
|
|
648
|
+
payload = {
|
|
649
|
+
"model": model,
|
|
650
|
+
"messages": msgs,
|
|
651
|
+
"temperature": temperature,
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if json_mode:
|
|
655
|
+
payload["response_format"] = {"type": "json_object"}
|
|
656
|
+
if msgs[-1]["role"] == "user":
|
|
657
|
+
msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
|
|
658
|
+
|
|
659
|
+
client = self._get_async_client(180.0)
|
|
660
|
+
response = await client.post(
|
|
661
|
+
f"{self.base_url}/chat/completions",
|
|
662
|
+
headers=self._headers(),
|
|
663
|
+
json=payload,
|
|
664
|
+
)
|
|
665
|
+
if response.status_code in {401, 403} and self._refresh_access_token_from_gcloud():
|
|
82
666
|
response = await client.post(
|
|
83
667
|
f"{self.base_url}/chat/completions",
|
|
84
|
-
headers=
|
|
85
|
-
json=payload
|
|
86
|
-
)
|
|
87
|
-
response.raise_for_status()
|
|
88
|
-
data = response.json()
|
|
89
|
-
|
|
90
|
-
resp = LLMResponse(
|
|
91
|
-
content=data["choices"][0]["message"]["content"],
|
|
92
|
-
model=data["model"],
|
|
93
|
-
usage=data.get("usage", {}),
|
|
94
|
-
raw_response=data
|
|
668
|
+
headers=self._headers(),
|
|
669
|
+
json=payload,
|
|
95
670
|
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"usage": resp.usage,
|
|
109
|
-
}
|
|
110
|
-
with open(log_file, "a", encoding="utf-8") as f:
|
|
111
|
-
f.write(json.dumps(log_payload) + "\n")
|
|
112
|
-
except Exception as e:
|
|
113
|
-
import logging as _log
|
|
114
|
-
_log.getLogger(__name__).debug("Failed to log model call: %s", e)
|
|
115
|
-
|
|
116
|
-
return resp
|
|
671
|
+
raise_for_provider_status(response, "Vertex AI")
|
|
672
|
+
data = response.json()
|
|
673
|
+
|
|
674
|
+
resp = LLMResponse(
|
|
675
|
+
content=data["choices"][0]["message"]["content"],
|
|
676
|
+
model=data["model"],
|
|
677
|
+
usage=data.get("usage", {}),
|
|
678
|
+
raw_response=data
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
_log_model_call(payload, data, resp.usage, self.project_root, task_id=task_id, run_id=run_id)
|
|
682
|
+
return resp
|
|
117
683
|
|
|
118
684
|
class MockProvider(Provider):
|
|
119
685
|
"""Mock provider for dry runs and testing."""
|
|
@@ -127,7 +693,9 @@ class MockProvider(Provider):
|
|
|
127
693
|
model: str,
|
|
128
694
|
messages: List[Dict[str, str]],
|
|
129
695
|
temperature: float = 0.0,
|
|
130
|
-
json_mode: bool = False
|
|
696
|
+
json_mode: bool = False,
|
|
697
|
+
task_id: Optional[str] = None,
|
|
698
|
+
run_id: Optional[str] = None,
|
|
131
699
|
) -> LLMResponse:
|
|
132
700
|
res = self.responses.get(model, '{"mock": "response"}')
|
|
133
701
|
|