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,439 @@
|
|
|
1
|
+
"""Credential manager for LLM providers.
|
|
2
|
+
|
|
3
|
+
Injectable singleton with TTL caching and proactive refresh.
|
|
4
|
+
Per-provider async locks prevent thundering herd on token refresh.
|
|
5
|
+
|
|
6
|
+
Note: Does NOT call load_dotenv() - that's the responsibility of the
|
|
7
|
+
CLI entrypoint or config loader to avoid import-time side effects.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from forge.core.auth.protocols import SecretsProvider
|
|
19
|
+
|
|
20
|
+
from .detection import ProviderType
|
|
21
|
+
from .errors import NoApiKeyError
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
DEFAULT_TTL = 3600.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_litellm_remote_base_url() -> str:
|
|
29
|
+
"""Get remote LiteLLM base URL.
|
|
30
|
+
|
|
31
|
+
Resolution order:
|
|
32
|
+
1. Template/proxy config (config.proxy.litellm.base_url)
|
|
33
|
+
2. LITELLM_BASE_URL environment variable
|
|
34
|
+
3. Credential file (~/.forge/credentials.yaml)
|
|
35
|
+
4. Error if none set
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
from forge.config import config
|
|
39
|
+
|
|
40
|
+
base_url = config.proxy.litellm.base_url
|
|
41
|
+
if base_url:
|
|
42
|
+
logger.debug(f"Using LiteLLM base_url from template: {base_url}")
|
|
43
|
+
return base_url
|
|
44
|
+
except (ImportError, AttributeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
from forge.core.auth.template_secrets import resolve_env_or_credential
|
|
48
|
+
|
|
49
|
+
resolved_url = resolve_env_or_credential("LITELLM_BASE_URL")
|
|
50
|
+
if resolved_url:
|
|
51
|
+
logger.debug("Using LiteLLM base_url from env/credential file")
|
|
52
|
+
return resolved_url
|
|
53
|
+
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"LiteLLM remote base_url not configured. "
|
|
56
|
+
"Use 'forge proxy create <template> --base-url <url>' or "
|
|
57
|
+
"'forge auth login -c litellm-remote' to store it."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_litellm_local_base_url() -> str:
|
|
62
|
+
"""Get local LiteLLM base URL.
|
|
63
|
+
|
|
64
|
+
Resolution order:
|
|
65
|
+
1. Template config (config.proxy.litellm.base_url)
|
|
66
|
+
2. LITELLM_LOCAL_BASE_URL environment variable
|
|
67
|
+
3. Derive from backend_dependency.port (http://localhost:{port})
|
|
68
|
+
4. Error if none available
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
from forge.config import config
|
|
72
|
+
|
|
73
|
+
base_url = config.proxy.litellm.base_url
|
|
74
|
+
if base_url:
|
|
75
|
+
logger.debug(f"Using LiteLLM base_url from template: {base_url}")
|
|
76
|
+
return base_url
|
|
77
|
+
except (ImportError, AttributeError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
env_url = os.environ.get("LITELLM_LOCAL_BASE_URL")
|
|
81
|
+
if env_url:
|
|
82
|
+
logger.debug(f"Using LiteLLM base_url from LITELLM_LOCAL_BASE_URL: {env_url}")
|
|
83
|
+
return env_url
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
from forge.config import config
|
|
87
|
+
|
|
88
|
+
dep = config.proxy.backend_dependency
|
|
89
|
+
if dep and dep.port:
|
|
90
|
+
derived = f"http://localhost:{dep.port}"
|
|
91
|
+
logger.debug(f"Using LiteLLM base_url derived from backend_dependency: {derived}")
|
|
92
|
+
return derived
|
|
93
|
+
except (ImportError, AttributeError):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"LiteLLM local base_url not configured. "
|
|
98
|
+
"Set LITELLM_LOCAL_BASE_URL environment variable or use a template with backend_dependency."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
OPENROUTER_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _format_credential_error(
|
|
106
|
+
env_var: str,
|
|
107
|
+
*,
|
|
108
|
+
context: str | None = None,
|
|
109
|
+
extra_hint: str | None = None,
|
|
110
|
+
profile: str | None = None,
|
|
111
|
+
) -> str | None:
|
|
112
|
+
"""Build actionable error using capabilities registry. Returns None on failure."""
|
|
113
|
+
try:
|
|
114
|
+
from forge.core.auth.capabilities import (
|
|
115
|
+
credential_for_env_var,
|
|
116
|
+
format_missing_credential_error,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
cred = credential_for_env_var(env_var)
|
|
120
|
+
if not cred:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
from forge.runtime_config import get_runtime_config
|
|
125
|
+
|
|
126
|
+
ignore_env = get_runtime_config().auth_ignore_env
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.debug("Could not read auth_ignore_env; formatting credential error without env-ignored note: %s", e)
|
|
129
|
+
ignore_env = False
|
|
130
|
+
|
|
131
|
+
return format_missing_credential_error(
|
|
132
|
+
cred,
|
|
133
|
+
missing_vars=[env_var],
|
|
134
|
+
context=context,
|
|
135
|
+
extra_hint=extra_hint,
|
|
136
|
+
profile=profile,
|
|
137
|
+
env_ignored=ignore_env,
|
|
138
|
+
)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.debug("Could not format missing credential error for %s: %s", env_var, e)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _get_openrouter_base_url() -> str:
|
|
145
|
+
"""Get OpenRouter base URL.
|
|
146
|
+
|
|
147
|
+
Resolution order:
|
|
148
|
+
1. Template/proxy config (config.proxy.openrouter.base_url)
|
|
149
|
+
2. OPENROUTER_BASE_URL environment variable or credential file
|
|
150
|
+
3. Default: https://openrouter.ai/api/v1
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
from forge.config import config
|
|
154
|
+
|
|
155
|
+
base_url = config.proxy.openrouter.base_url
|
|
156
|
+
if base_url:
|
|
157
|
+
logger.debug(f"Using OpenRouter base_url from template: {base_url}")
|
|
158
|
+
return base_url
|
|
159
|
+
except (ImportError, AttributeError):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
from forge.core.auth.template_secrets import resolve_env_or_credential
|
|
163
|
+
|
|
164
|
+
resolved = resolve_env_or_credential("OPENROUTER_BASE_URL")
|
|
165
|
+
if resolved:
|
|
166
|
+
logger.debug(f"Using OpenRouter base_url from env/credential file: {resolved}")
|
|
167
|
+
return resolved
|
|
168
|
+
|
|
169
|
+
return OPENROUTER_DEFAULT_BASE_URL
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class CredentialManager:
|
|
173
|
+
"""Injectable credential manager with TTL caching and proactive refresh.
|
|
174
|
+
|
|
175
|
+
Use CredentialManager.default() to get the global instance,
|
|
176
|
+
or create your own instance for testing.
|
|
177
|
+
|
|
178
|
+
Configuration:
|
|
179
|
+
Base URLs are read from templates (config.proxy.litellm.base_url).
|
|
180
|
+
Remote templates can also fall back to LITELLM_BASE_URL env var.
|
|
181
|
+
|
|
182
|
+
Environment Variables (Secrets Only):
|
|
183
|
+
CREDENTIAL_CACHE_TTL: Global default TTL in seconds (default: 3600)
|
|
184
|
+
LITELLM_API_KEY: Remote LiteLLM API key (secret)
|
|
185
|
+
LITELLM_LOCAL_API_KEY: Local LiteLLM API key (optional secret)
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
default_ttl: Default TTL for cached credentials in seconds.
|
|
189
|
+
secrets: Optional SecretsProvider for reading secrets. If not provided,
|
|
190
|
+
falls back to reading directly from os.environ.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
_default_instance: "CredentialManager | None" = None
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
default_ttl: float = DEFAULT_TTL,
|
|
198
|
+
secrets: SecretsProvider | None = None,
|
|
199
|
+
) -> None:
|
|
200
|
+
# Cache structure: provider -> (credentials, fetch_time, ttl)
|
|
201
|
+
self._cache: dict[str, tuple[dict[str, Any], float, float]] = {}
|
|
202
|
+
self._default_ttl = float(os.getenv("CREDENTIAL_CACHE_TTL", str(default_ttl)))
|
|
203
|
+
# Per-provider locks to prevent concurrent credential fetches
|
|
204
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
205
|
+
self._secrets = secrets
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def default(cls) -> "CredentialManager":
|
|
209
|
+
"""Get or create the default global instance.
|
|
210
|
+
|
|
211
|
+
Wires EnvSecretsProvider -> FileSecretsProvider chain so credentials
|
|
212
|
+
from ~/.forge/credentials.yaml are available as fallback to env vars.
|
|
213
|
+
``EnvSecretsProvider`` reads ``auth_ignore_env`` lazily per-call,
|
|
214
|
+
so config changes take effect without resetting the singleton.
|
|
215
|
+
"""
|
|
216
|
+
if cls._default_instance is None:
|
|
217
|
+
from forge.core.auth import ChainSecretsProvider, EnvSecretsProvider
|
|
218
|
+
from forge.core.auth.secrets import FileSecretsProvider
|
|
219
|
+
|
|
220
|
+
secrets = ChainSecretsProvider(
|
|
221
|
+
EnvSecretsProvider(),
|
|
222
|
+
FileSecretsProvider(),
|
|
223
|
+
)
|
|
224
|
+
cls._default_instance = cls(secrets=secrets)
|
|
225
|
+
return cls._default_instance
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def reset_default(cls) -> None:
|
|
229
|
+
"""Reset the default instance (for testing)."""
|
|
230
|
+
cls._default_instance = None
|
|
231
|
+
|
|
232
|
+
def _get_lock(self, provider: str) -> asyncio.Lock:
|
|
233
|
+
"""Get or create a lock for the given provider."""
|
|
234
|
+
if provider not in self._locks:
|
|
235
|
+
self._locks[provider] = asyncio.Lock()
|
|
236
|
+
return self._locks[provider]
|
|
237
|
+
|
|
238
|
+
def _resolve_secrets(self) -> SecretsProvider:
|
|
239
|
+
"""Return configured secrets provider, falling back to env-only."""
|
|
240
|
+
if self._secrets is not None:
|
|
241
|
+
return self._secrets
|
|
242
|
+
from forge.core.auth import EnvSecretsProvider
|
|
243
|
+
|
|
244
|
+
return EnvSecretsProvider()
|
|
245
|
+
|
|
246
|
+
async def get_credentials(
|
|
247
|
+
self,
|
|
248
|
+
provider: ProviderType,
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
"""Get credentials for a provider, refreshing if needed.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
provider: Provider type to get credentials for.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Dictionary with provider-specific credentials.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
NoApiKeyError: If required credentials are not configured.
|
|
260
|
+
"""
|
|
261
|
+
# Check without lock; common path avoids lock contention.
|
|
262
|
+
if provider in self._cache:
|
|
263
|
+
creds, fetch_time, ttl = self._cache[provider]
|
|
264
|
+
age = time.monotonic() - fetch_time
|
|
265
|
+
if age < ttl:
|
|
266
|
+
logger.debug(f"Using cached credentials for {provider} (age: {age:.0f}s)")
|
|
267
|
+
return creds
|
|
268
|
+
|
|
269
|
+
async with self._get_lock(provider):
|
|
270
|
+
# Double-checked locking: another coroutine may have refreshed while we waited.
|
|
271
|
+
if provider in self._cache:
|
|
272
|
+
creds, fetch_time, ttl = self._cache[provider]
|
|
273
|
+
if time.monotonic() - fetch_time < ttl:
|
|
274
|
+
return creds
|
|
275
|
+
|
|
276
|
+
creds = await self._fetch_credentials(provider)
|
|
277
|
+
self._cache[provider] = (creds, time.monotonic(), self._default_ttl)
|
|
278
|
+
logger.info(f"Cached fresh credentials for {provider}")
|
|
279
|
+
return creds
|
|
280
|
+
|
|
281
|
+
async def _fetch_credentials(self, provider: ProviderType) -> dict[str, Any]:
|
|
282
|
+
"""Fetch credentials for a provider from environment.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
provider: Provider type to fetch credentials for.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Dictionary with provider-specific credentials.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
NoApiKeyError: If required credentials are not configured.
|
|
292
|
+
"""
|
|
293
|
+
if provider == "litellm_remote":
|
|
294
|
+
return self._get_litellm_remote_credentials()
|
|
295
|
+
elif provider == "litellm_local":
|
|
296
|
+
return self._get_litellm_local_credentials()
|
|
297
|
+
elif provider == "anthropic":
|
|
298
|
+
return self._get_anthropic_credentials()
|
|
299
|
+
elif provider == "openrouter":
|
|
300
|
+
return self._get_openrouter_credentials()
|
|
301
|
+
else:
|
|
302
|
+
raise ValueError(f"Unknown provider: {provider}")
|
|
303
|
+
|
|
304
|
+
def _get_litellm_remote_credentials(self) -> dict[str, Any]:
|
|
305
|
+
"""Get credentials for remote LiteLLM.
|
|
306
|
+
|
|
307
|
+
Uses unified config for base_url (set by template), with LITELLM_BASE_URL env fallback.
|
|
308
|
+
API key is required for remote endpoints (non-localhost).
|
|
309
|
+
SSL certificate from SSL_CERT_FILE or REQUESTS_CA_BUNDLE for remote proxies.
|
|
310
|
+
"""
|
|
311
|
+
secrets = self._resolve_secrets()
|
|
312
|
+
base_url = _get_litellm_remote_base_url()
|
|
313
|
+
api_key = secrets.get("LITELLM_API_KEY", "")
|
|
314
|
+
|
|
315
|
+
# For remote LiteLLM, API key is required (unless localhost)
|
|
316
|
+
is_local = "localhost" in base_url or "127.0.0.1" in base_url
|
|
317
|
+
if not api_key and not is_local:
|
|
318
|
+
raise NoApiKeyError(
|
|
319
|
+
"litellm_remote",
|
|
320
|
+
"LITELLM_API_KEY",
|
|
321
|
+
detail=_format_credential_error("LITELLM_API_KEY"),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# SSL cert paths are non-secret, read directly from env
|
|
325
|
+
ssl_cert = os.getenv("SSL_CERT_FILE") or os.getenv("REQUESTS_CA_BUNDLE")
|
|
326
|
+
|
|
327
|
+
result = {
|
|
328
|
+
"base_url": base_url,
|
|
329
|
+
"api_key": api_key,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if ssl_cert:
|
|
333
|
+
result["ssl_cert"] = ssl_cert
|
|
334
|
+
logger.debug(f"Using SSL certificate for remote LiteLLM: {ssl_cert}")
|
|
335
|
+
|
|
336
|
+
return result
|
|
337
|
+
|
|
338
|
+
def _get_litellm_local_credentials(self) -> dict[str, Any]:
|
|
339
|
+
"""Get credentials for local LiteLLM (personal API keys).
|
|
340
|
+
|
|
341
|
+
Uses unified config for base_url (set by template overlay), with env fallback.
|
|
342
|
+
API key is optional for local - proxy handles auth via GEMINI_API_KEY etc.
|
|
343
|
+
"""
|
|
344
|
+
secrets = self._resolve_secrets()
|
|
345
|
+
base_url = _get_litellm_local_base_url()
|
|
346
|
+
api_key = secrets.get("LITELLM_LOCAL_API_KEY", "not-needed")
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
"base_url": base_url,
|
|
350
|
+
"api_key": api_key,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
def _get_anthropic_credentials(self) -> dict[str, Any]:
|
|
354
|
+
"""Get credentials for direct Anthropic API."""
|
|
355
|
+
secrets = self._resolve_secrets()
|
|
356
|
+
api_key = secrets.get("ANTHROPIC_API_KEY")
|
|
357
|
+
if not api_key:
|
|
358
|
+
raise NoApiKeyError(
|
|
359
|
+
"anthropic",
|
|
360
|
+
"ANTHROPIC_API_KEY",
|
|
361
|
+
detail=_format_credential_error(
|
|
362
|
+
"ANTHROPIC_API_KEY",
|
|
363
|
+
extra_hint="Or use --subprocess-proxy to route through an existing proxy.",
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
"api_key": api_key,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
def _get_openrouter_credentials(self) -> dict[str, Any]:
|
|
372
|
+
"""Get credentials for OpenRouter.
|
|
373
|
+
|
|
374
|
+
Resolution mirrors the LiteLLM remote pattern: config base_url first,
|
|
375
|
+
then env var, then default. API key is always required.
|
|
376
|
+
"""
|
|
377
|
+
secrets = self._resolve_secrets()
|
|
378
|
+
api_key = secrets.get("OPENROUTER_API_KEY")
|
|
379
|
+
if not api_key:
|
|
380
|
+
raise NoApiKeyError(
|
|
381
|
+
"openrouter",
|
|
382
|
+
"OPENROUTER_API_KEY",
|
|
383
|
+
detail=_format_credential_error("OPENROUTER_API_KEY"),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
base_url = _get_openrouter_base_url()
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
"api_key": api_key,
|
|
390
|
+
"base_url": base_url,
|
|
391
|
+
"extra_headers": {
|
|
392
|
+
"HTTP-Referer": "https://github.com/hapa1i/multi-forge",
|
|
393
|
+
"X-OpenRouter-Title": "Multi-Forge",
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async def invalidate(
|
|
398
|
+
self,
|
|
399
|
+
provider: ProviderType,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Invalidate cached credentials for a provider.
|
|
402
|
+
|
|
403
|
+
Call this when authentication fails to force a refresh.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
provider: Provider whose credentials should be invalidated.
|
|
407
|
+
"""
|
|
408
|
+
async with self._get_lock(provider):
|
|
409
|
+
if provider in self._cache:
|
|
410
|
+
del self._cache[provider]
|
|
411
|
+
logger.info(f"Invalidated cached credentials for {provider}")
|
|
412
|
+
|
|
413
|
+
def get_cache_status(self) -> dict[str, Any]:
|
|
414
|
+
"""Get current cache status for monitoring.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Dictionary with cache information per provider.
|
|
418
|
+
"""
|
|
419
|
+
status: dict[str, Any] = {
|
|
420
|
+
"default_ttl": self._default_ttl,
|
|
421
|
+
"providers": {},
|
|
422
|
+
}
|
|
423
|
+
current_time = time.monotonic()
|
|
424
|
+
|
|
425
|
+
for provider, (_, fetch_time, ttl) in self._cache.items():
|
|
426
|
+
age = current_time - fetch_time
|
|
427
|
+
status["providers"][provider] = {
|
|
428
|
+
"age_seconds": round(age, 1),
|
|
429
|
+
"ttl_seconds": ttl,
|
|
430
|
+
"remaining_seconds": round(max(0, ttl - age), 1),
|
|
431
|
+
"expired": age >= ttl,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return status
|
|
435
|
+
|
|
436
|
+
def clear_cache(self) -> None:
|
|
437
|
+
"""Clear all cached credentials."""
|
|
438
|
+
self._cache.clear()
|
|
439
|
+
logger.info("Cleared all cached credentials")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Provider detection for LLM client routing.
|
|
2
|
+
|
|
3
|
+
This module provides prefix-based provider detection for model IDs.
|
|
4
|
+
core.llm only supports prefixed canonical IDs (e.g., "openai/gpt-5.2").
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
# Provider type - all supported providers (some may not be implemented yet)
|
|
10
|
+
ProviderType = Literal["litellm_remote", "litellm_local", "anthropic", "openrouter"]
|
|
11
|
+
|
|
12
|
+
# Prefixes that route to remote LiteLLM
|
|
13
|
+
LITELLM_REMOTE_PREFIXES = (
|
|
14
|
+
"openai/",
|
|
15
|
+
"anthropic/",
|
|
16
|
+
"vertex_ai/",
|
|
17
|
+
"bedrock/",
|
|
18
|
+
"replicate/",
|
|
19
|
+
"together_ai/",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Prefixes that route to local LiteLLM (personal API keys)
|
|
23
|
+
LITELLM_LOCAL_PREFIXES = ("gemini/",)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def detect_provider(model: str) -> ProviderType:
|
|
27
|
+
"""Detect provider from prefixed model ID.
|
|
28
|
+
|
|
29
|
+
IMPORTANT: core.llm only supports prefixed canonical IDs.
|
|
30
|
+
Unprefixed models (claude-*, gpt-*) are NOT supported in v1.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
model: Model identifier with provider prefix (e.g., "openai/gpt-5.2")
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
ProviderType indicating which provider should handle this model.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If model ID is not prefixed (unprefixed models not supported).
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
>>> detect_provider("openai/gpt-5.2")
|
|
43
|
+
'litellm_remote'
|
|
44
|
+
>>> detect_provider("vertex_ai/gemini-3.1-pro-preview")
|
|
45
|
+
'litellm_remote'
|
|
46
|
+
>>> detect_provider("gemini/gemini-2.0-flash")
|
|
47
|
+
'litellm_local'
|
|
48
|
+
>>> detect_provider("anthropic/claude-sonnet-4")
|
|
49
|
+
'litellm_remote'
|
|
50
|
+
"""
|
|
51
|
+
clean_name = model.lower()
|
|
52
|
+
|
|
53
|
+
# Check for remote LiteLLM prefixes
|
|
54
|
+
if any(clean_name.startswith(prefix) for prefix in LITELLM_REMOTE_PREFIXES):
|
|
55
|
+
return "litellm_remote"
|
|
56
|
+
|
|
57
|
+
# Check for local LiteLLM prefixes
|
|
58
|
+
if any(clean_name.startswith(prefix) for prefix in LITELLM_LOCAL_PREFIXES):
|
|
59
|
+
return "litellm_local"
|
|
60
|
+
|
|
61
|
+
# Unprefixed models are not supported in core.llm v1
|
|
62
|
+
# The user should use prefixed model IDs
|
|
63
|
+
if "/" not in model:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Unprefixed model ID '{model}' not supported in core.llm. "
|
|
66
|
+
f"Use prefixed canonical IDs like 'openai/{model}' or 'anthropic/{model}'."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Unknown prefix -- fail-closed (reject rather than silently route to wrong backend)
|
|
70
|
+
known = sorted({*LITELLM_REMOTE_PREFIXES, *LITELLM_LOCAL_PREFIXES})
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Unknown model prefix in '{model}'. Known prefixes: {', '.join(known)}. "
|
|
73
|
+
"Use a prefixed canonical ID like 'openai/gpt-5.2' or 'gemini/gemini-3.1-pro-preview'."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_implemented(provider: ProviderType) -> bool:
|
|
78
|
+
"""Check if a provider has an implemented client.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
provider: Provider type to check.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if the provider's client is implemented, False otherwise.
|
|
85
|
+
"""
|
|
86
|
+
return provider in ("litellm_remote", "litellm_local", "openrouter")
|
forge/core/llm/errors.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Exception hierarchy for LLM client abstraction."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LLMError(Exception):
|
|
5
|
+
"""Base exception for all LLM-related errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NoApiKeyError(LLMError):
|
|
11
|
+
"""Raised when required API key is not configured."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, provider: str, env_var: str, *, detail: str | None = None) -> None:
|
|
14
|
+
self.provider = provider
|
|
15
|
+
self.env_var = env_var
|
|
16
|
+
self.detail = detail
|
|
17
|
+
msg = detail if detail else f"API key not configured for {provider}. Set {env_var}."
|
|
18
|
+
super().__init__(msg)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthenticationError(LLMError):
|
|
22
|
+
"""Raised when authentication fails."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, provider: str, message: str) -> None:
|
|
25
|
+
self.provider = provider
|
|
26
|
+
super().__init__(f"Authentication failed for {provider}: {message}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProviderError(LLMError):
|
|
30
|
+
"""Wrapper for provider-specific errors."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, provider: str, original: Exception) -> None:
|
|
33
|
+
self.provider = provider
|
|
34
|
+
self.original = original
|
|
35
|
+
super().__init__(f"{provider} error: {original}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UnsupportedParamError(LLMError):
|
|
39
|
+
"""Raised when strict mode encounters unsupported parameter."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, param: str, provider: str) -> None:
|
|
42
|
+
self.param = param
|
|
43
|
+
self.provider = provider
|
|
44
|
+
super().__init__(f"Parameter '{param}' not supported by {provider}")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""LLM client protocol definition."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncGenerator, Protocol
|
|
4
|
+
|
|
5
|
+
from .types import CompletionResponse, Message, ModelHyperparameters, StreamEvent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LLMClient(Protocol):
|
|
9
|
+
"""Async-first LLM client protocol.
|
|
10
|
+
|
|
11
|
+
All provider implementations must implement this interface.
|
|
12
|
+
The client is async-first; use SyncAdapter for synchronous usage.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def model(self) -> str:
|
|
17
|
+
"""The model this client is configured for."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
async def complete(
|
|
21
|
+
self,
|
|
22
|
+
messages: list[Message],
|
|
23
|
+
*,
|
|
24
|
+
tools: list[dict[str, Any]] | None = None,
|
|
25
|
+
hyperparams: ModelHyperparameters | None = None,
|
|
26
|
+
) -> CompletionResponse:
|
|
27
|
+
"""Non-streaming completion.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
messages: List of messages in the conversation.
|
|
31
|
+
tools: Optional list of tool definitions (JSON Schema format).
|
|
32
|
+
hyperparams: Optional hyperparameters to override client defaults.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
CompletionResponse with text, optional tool_calls, and usage.
|
|
36
|
+
"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def stream(
|
|
40
|
+
self,
|
|
41
|
+
messages: list[Message],
|
|
42
|
+
*,
|
|
43
|
+
tools: list[dict[str, Any]] | None = None,
|
|
44
|
+
hyperparams: ModelHyperparameters | None = None,
|
|
45
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
46
|
+
"""Streaming completion.
|
|
47
|
+
|
|
48
|
+
Yields canonical StreamEvent objects. For tool calls, accumulate
|
|
49
|
+
ToolCallDelta events until response_end, then parse into ToolCall.
|
|
50
|
+
|
|
51
|
+
Note: Returns an async generator directly (not an async def).
|
|
52
|
+
Use `async for event in client.stream(...)` to iterate.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
messages: List of messages in the conversation.
|
|
56
|
+
tools: Optional list of tool definitions (JSON Schema format).
|
|
57
|
+
hyperparams: Optional hyperparameters to override client defaults.
|
|
58
|
+
|
|
59
|
+
Yields:
|
|
60
|
+
StreamEvent objects (text_delta, tool_call_delta, response_end, usage, error).
|
|
61
|
+
"""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
async def count_tokens(
|
|
65
|
+
self,
|
|
66
|
+
messages: list[Message],
|
|
67
|
+
tools: list[dict[str, Any]] | None = None,
|
|
68
|
+
) -> int:
|
|
69
|
+
"""Estimate token count for messages and tools.
|
|
70
|
+
|
|
71
|
+
Accuracy varies by provider. Use for rough estimates only.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
messages: List of messages to count.
|
|
75
|
+
tools: Optional list of tool definitions to include in count.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Estimated token count.
|
|
79
|
+
"""
|
|
80
|
+
...
|