@oneciel-ai/ciel-runtime 0.1.1 → 0.2.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 +132 -0
- package/ciel-runtime-menu.py +56 -6
- package/ciel_runtime.py +9379 -35909
- package/ciel_runtime_support/advisor_client.py +193 -0
- package/ciel_runtime_support/advisor_policy.py +320 -0
- package/ciel_runtime_support/advisor_refinement.py +160 -0
- package/ciel_runtime_support/advisor_request_builder.py +261 -0
- package/ciel_runtime_support/agy_installer.py +169 -0
- package/ciel_runtime_support/agy_mcp_restore.py +182 -0
- package/ciel_runtime_support/anthropic_model_policy.py +186 -0
- package/ciel_runtime_support/anthropic_response_writer.py +255 -0
- package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
- package/ciel_runtime_support/api_key_cooldown.py +159 -0
- package/ciel_runtime_support/architecture.py +488 -1
- package/ciel_runtime_support/architecture_budget.py +42 -0
- package/ciel_runtime_support/channel_backlog.py +90 -0
- package/ciel_runtime_support/channel_cli.py +119 -0
- package/ciel_runtime_support/channel_compact_injection.py +82 -0
- package/ciel_runtime_support/channel_compact_poll.py +67 -0
- package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
- package/ciel_runtime_support/channel_config_service.py +281 -0
- package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
- package/ciel_runtime_support/channel_connection_registry.py +128 -0
- package/ciel_runtime_support/channel_connection_worker.py +284 -0
- package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
- package/ciel_runtime_support/channel_cursor_repository.py +89 -0
- package/ciel_runtime_support/channel_cursor_service.py +178 -0
- package/ciel_runtime_support/channel_event_identity.py +212 -0
- package/ciel_runtime_support/channel_event_projection.py +315 -0
- package/ciel_runtime_support/channel_inflight.py +127 -0
- package/ciel_runtime_support/channel_injection.py +115 -0
- package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
- package/ciel_runtime_support/channel_launch_policy.py +180 -0
- package/ciel_runtime_support/channel_llm_context.py +156 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
- package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
- package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
- package/ciel_runtime_support/channel_mcp_tools.py +240 -0
- package/ciel_runtime_support/channel_mcp_transport.py +394 -0
- package/ciel_runtime_support/channel_message_dedupe.py +65 -0
- package/ciel_runtime_support/channel_message_policy.py +256 -0
- package/ciel_runtime_support/channel_message_prompt.py +305 -0
- package/ciel_runtime_support/channel_message_repository.py +234 -0
- package/ciel_runtime_support/channel_notification_projection.py +217 -0
- package/ciel_runtime_support/channel_panel.py +162 -0
- package/ciel_runtime_support/channel_pending_injection.py +209 -0
- package/ciel_runtime_support/channel_pending_poll.py +109 -0
- package/ciel_runtime_support/channel_probe_cache.py +433 -0
- package/ciel_runtime_support/channel_probe_report.py +101 -0
- package/ciel_runtime_support/channel_runtime_environment.py +181 -0
- package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
- package/ciel_runtime_support/channel_session_repository.py +90 -0
- package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
- package/ciel_runtime_support/channel_terminal_input.py +277 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
- package/ciel_runtime_support/channel_tool_context.py +166 -0
- package/ciel_runtime_support/channel_transcript.py +414 -0
- package/ciel_runtime_support/channel_transcript_repository.py +96 -0
- package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
- package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
- package/ciel_runtime_support/chat_files.py +138 -0
- package/ciel_runtime_support/chat_http_controller.py +235 -0
- package/ciel_runtime_support/claude_environment.py +375 -0
- package/ciel_runtime_support/claude_router.py +247 -193
- package/ciel_runtime_support/cli_dispatch.py +792 -0
- package/ciel_runtime_support/cli_parser.py +165 -0
- package/ciel_runtime_support/cli_usage.py +100 -0
- package/ciel_runtime_support/codex_app_server.py +20 -5
- package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
- package/ciel_runtime_support/codex_cli.py +42 -6
- package/ciel_runtime_support/codex_config.py +323 -0
- package/ciel_runtime_support/codex_launch_configuration.py +240 -0
- package/ciel_runtime_support/codex_launch_policy.py +66 -0
- package/ciel_runtime_support/codex_mcp_integration.py +195 -0
- package/ciel_runtime_support/codex_mcp_restore.py +304 -0
- package/ciel_runtime_support/codex_model_catalog.py +133 -0
- package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
- package/ciel_runtime_support/codex_router.py +147 -1
- package/ciel_runtime_support/codex_session_repository.py +115 -0
- package/ciel_runtime_support/codex_session_selection.py +114 -0
- package/ciel_runtime_support/command_asset_installer.py +103 -0
- package/ciel_runtime_support/compatibility_probe.py +295 -0
- package/ciel_runtime_support/compatibility_protocol.py +251 -0
- package/ciel_runtime_support/compatibility_runtime.py +166 -0
- package/ciel_runtime_support/compatibility_test.py +370 -0
- package/ciel_runtime_support/config_migrations.py +307 -0
- package/ciel_runtime_support/config_repository.py +175 -0
- package/ciel_runtime_support/config_value_codec.py +64 -0
- package/ciel_runtime_support/configuration_cli.py +374 -0
- package/ciel_runtime_support/context_compaction.py +280 -0
- package/ciel_runtime_support/context_setup.py +208 -0
- package/ciel_runtime_support/context_summary_policy.py +392 -0
- package/ciel_runtime_support/credential_cli.py +104 -0
- package/ciel_runtime_support/credential_management.py +261 -0
- package/ciel_runtime_support/credentials.py +269 -0
- package/ciel_runtime_support/executable_discovery.py +141 -0
- package/ciel_runtime_support/github_copilot_oauth.py +335 -0
- package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
- package/ciel_runtime_support/header_forwarding.py +73 -0
- package/ciel_runtime_support/headless_config.py +221 -0
- package/ciel_runtime_support/http_response.py +129 -0
- package/ciel_runtime_support/install_diagnostics.py +149 -0
- package/ciel_runtime_support/kimi_identity.py +123 -0
- package/ciel_runtime_support/launch_diagnostics.py +204 -0
- package/ciel_runtime_support/launch_state.py +127 -0
- package/ciel_runtime_support/live_api_key_controller.py +58 -0
- package/ciel_runtime_support/llm_config_http.py +148 -0
- package/ciel_runtime_support/llm_option_config.py +259 -0
- package/ciel_runtime_support/llm_presentation_data.py +447 -0
- package/ciel_runtime_support/llm_presets.py +773 -0
- package/ciel_runtime_support/lm_studio_runtime.py +401 -0
- package/ciel_runtime_support/managed_mcp_config.py +144 -0
- package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
- package/ciel_runtime_support/managed_service_cleanup.py +89 -0
- package/ciel_runtime_support/mcp_config_reader.py +230 -0
- package/ciel_runtime_support/mcp_http_proxy.py +607 -0
- package/ciel_runtime_support/mcp_inventory.py +59 -0
- package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
- package/ciel_runtime_support/mcp_probe_codec.py +136 -0
- package/ciel_runtime_support/mcp_probe_transport.py +328 -0
- package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
- package/ciel_runtime_support/mcp_proxy_config.py +107 -0
- package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
- package/ciel_runtime_support/mcp_proxy_process.py +560 -0
- package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
- package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
- package/ciel_runtime_support/mcp_transport.py +146 -0
- package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
- package/ciel_runtime_support/model_catalog_projection.py +61 -0
- package/ciel_runtime_support/model_context_hints.py +109 -0
- package/ciel_runtime_support/model_panel.py +147 -0
- package/ciel_runtime_support/model_registry_repository.py +231 -0
- package/ciel_runtime_support/npm_runtime.py +191 -0
- package/ciel_runtime_support/ollama_catalog.py +462 -0
- package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
- package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
- package/ciel_runtime_support/ollama_context_sync.py +87 -0
- package/ciel_runtime_support/ollama_forwarding.py +449 -0
- package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
- package/ciel_runtime_support/openai_chat_router.py +64 -0
- package/ciel_runtime_support/openai_forwarding.py +194 -0
- package/ciel_runtime_support/openai_responses_router.py +291 -0
- package/ciel_runtime_support/openai_responses_stream.py +135 -0
- package/ciel_runtime_support/output_budget.py +89 -0
- package/ciel_runtime_support/package_lifecycle.py +217 -0
- package/ciel_runtime_support/plan_artifact_controller.py +104 -0
- package/ciel_runtime_support/prelaunch.py +959 -0
- package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
- package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
- package/ciel_runtime_support/prelaunch_terminal.py +764 -0
- package/ciel_runtime_support/process_control.py +708 -0
- package/ciel_runtime_support/prompt_compaction.py +322 -0
- package/ciel_runtime_support/prompt_injection.py +176 -0
- package/ciel_runtime_support/protocols/__init__.py +24 -0
- package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
- package/ciel_runtime_support/protocols/chat_projection.py +315 -0
- package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
- package/ciel_runtime_support/protocols/ollama_response.py +231 -0
- package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
- package/ciel_runtime_support/protocols/openai_responses.py +271 -0
- package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
- package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
- package/ciel_runtime_support/provider_adapters.py +160 -0
- package/ciel_runtime_support/provider_catalog_sources.py +316 -0
- package/ciel_runtime_support/provider_choice.py +201 -0
- package/ciel_runtime_support/provider_compatibility.py +165 -0
- package/ciel_runtime_support/provider_config_mutations.py +361 -0
- package/ciel_runtime_support/provider_configuration_service.py +162 -0
- package/ciel_runtime_support/provider_context.py +308 -0
- package/ciel_runtime_support/provider_contract_projection.py +73 -0
- package/ciel_runtime_support/provider_descriptor.py +82 -0
- package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
- package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
- package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
- package/ciel_runtime_support/provider_limits.py +457 -0
- package/ciel_runtime_support/provider_model_identity.py +139 -0
- package/ciel_runtime_support/provider_model_selection.py +431 -0
- package/ciel_runtime_support/provider_model_specs.py +142 -0
- package/ciel_runtime_support/provider_models.py +263 -0
- package/ciel_runtime_support/provider_network.py +176 -0
- package/ciel_runtime_support/provider_option_cli.py +238 -0
- package/ciel_runtime_support/provider_option_panel.py +275 -0
- package/ciel_runtime_support/provider_option_status.py +192 -0
- package/ciel_runtime_support/provider_policy.py +101 -0
- package/ciel_runtime_support/provider_query_policy.py +67 -0
- package/ciel_runtime_support/provider_readiness.py +112 -0
- package/ciel_runtime_support/provider_request_access.py +131 -0
- package/ciel_runtime_support/provider_request_builder.py +250 -0
- package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
- package/ciel_runtime_support/provider_runtime_info.py +113 -0
- package/ciel_runtime_support/provider_runtime_modes.py +150 -0
- package/ciel_runtime_support/provider_sampling_policy.py +46 -0
- package/ciel_runtime_support/provider_status.py +145 -0
- package/ciel_runtime_support/provider_timeout_policy.py +184 -0
- package/ciel_runtime_support/provider_tool_policy.py +145 -0
- package/ciel_runtime_support/providers/__init__.py +65 -0
- package/ciel_runtime_support/providers/anthropic.py +160 -0
- package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
- package/ciel_runtime_support/providers/base.py +232 -0
- package/ciel_runtime_support/providers/catalog.py +326 -0
- package/ciel_runtime_support/providers/cloud.py +194 -0
- package/ciel_runtime_support/providers/constants.py +54 -0
- package/ciel_runtime_support/providers/deepseek.py +115 -0
- package/ciel_runtime_support/providers/fireworks.py +158 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
- package/ciel_runtime_support/providers/kimi.py +297 -0
- package/ciel_runtime_support/providers/lm_studio.py +76 -0
- package/ciel_runtime_support/providers/meta.py +257 -0
- package/ciel_runtime_support/providers/native.py +194 -0
- package/ciel_runtime_support/providers/nim.py +69 -0
- package/ciel_runtime_support/providers/nvidia.py +158 -0
- package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
- package/ciel_runtime_support/providers/ollama.py +181 -0
- package/ciel_runtime_support/providers/ollama_context.py +195 -0
- package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
- package/ciel_runtime_support/providers/opencode.py +220 -0
- package/ciel_runtime_support/providers/opencode_go.py +37 -0
- package/ciel_runtime_support/providers/openrouter.py +57 -0
- package/ciel_runtime_support/providers/vllm.py +65 -0
- package/ciel_runtime_support/providers/zai.py +119 -0
- package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
- package/ciel_runtime_support/rate_limit_policy.py +117 -0
- package/ciel_runtime_support/rate_limit_repository.py +154 -0
- package/ciel_runtime_support/registry.py +46 -0
- package/ciel_runtime_support/request_shortcuts.py +253 -0
- package/ciel_runtime_support/request_trace.py +323 -0
- package/ciel_runtime_support/response_collection.py +209 -0
- package/ciel_runtime_support/router_access.py +238 -0
- package/ciel_runtime_support/router_client_lifecycle.py +366 -0
- package/ciel_runtime_support/router_health_policy.py +101 -0
- package/ciel_runtime_support/router_http.py +513 -0
- package/ciel_runtime_support/router_process_lifecycle.py +401 -0
- package/ciel_runtime_support/router_rate_limit_service.py +285 -0
- package/ciel_runtime_support/router_server_runtime.py +103 -0
- package/ciel_runtime_support/router_shortcuts.py +201 -0
- package/ciel_runtime_support/routing_fallback.py +73 -0
- package/ciel_runtime_support/runtime_activity_repository.py +143 -0
- package/ciel_runtime_support/runtime_adapters.py +104 -0
- package/ciel_runtime_support/runtime_command_factory.py +73 -0
- package/ciel_runtime_support/runtime_compatibility.py +50 -0
- package/ciel_runtime_support/runtime_constants.py +178 -0
- package/ciel_runtime_support/runtime_launch.py +1602 -0
- package/ciel_runtime_support/runtime_llm_options.py +312 -0
- package/ciel_runtime_support/runtime_logging.py +161 -0
- package/ciel_runtime_support/runtime_paths.py +157 -0
- package/ciel_runtime_support/runtime_restart.py +84 -0
- package/ciel_runtime_support/runtime_upgrade.py +149 -0
- package/ciel_runtime_support/secure_json_repository.py +55 -0
- package/ciel_runtime_support/session_import.py +356 -0
- package/ciel_runtime_support/settings_repository.py +8 -0
- package/ciel_runtime_support/slash_command_assets.py +211 -0
- package/ciel_runtime_support/sse_stream.py +57 -0
- package/ciel_runtime_support/sse_trace.py +225 -0
- package/ciel_runtime_support/statusline_script.py +593 -0
- package/ciel_runtime_support/statusline_settings.py +53 -0
- package/ciel_runtime_support/stream_chunk_policy.py +18 -0
- package/ciel_runtime_support/streaming_anthropic.py +1955 -0
- package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
- package/ciel_runtime_support/terminal_platform_io.py +127 -0
- package/ciel_runtime_support/timeout_profile.py +196 -0
- package/ciel_runtime_support/tool_dialects.py +85 -0
- package/ciel_runtime_support/tool_exposure_policy.py +63 -0
- package/ciel_runtime_support/tool_guard_hooks.py +218 -0
- package/ciel_runtime_support/tool_request_projection.py +96 -0
- package/ciel_runtime_support/tool_schema.py +483 -0
- package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
- package/ciel_runtime_support/ui_text.py +266 -0
- package/ciel_runtime_support/upstream_error_policy.py +104 -0
- package/ciel_runtime_support/upstream_retry.py +419 -0
- package/ciel_runtime_support/upstream_stream_io.py +106 -0
- package/ciel_runtime_support/usage_events.py +96 -0
- package/ciel_runtime_support/visible_stream_filters.py +130 -0
- package/ciel_runtime_support/web_endpoints.py +447 -0
- package/ciel_runtime_support/web_ui.py +915 -0
- package/ciel_runtime_support/web_ui_controller.py +189 -0
- package/ciel_runtime_support/windows_console_input.py +137 -0
- package/ciel_runtime_support/windows_console_mode.py +112 -0
- package/docs/Architecture.md +54 -0
- package/docs/Configuration.md +17 -0
- package/docs/Module-Map.md +1093 -26
- package/docs/Providers.md +46 -1
- package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
- package/npm-bin/run-ciel-runtime.js +22 -2
- package/package.json +9 -2
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Terminal controller for persisted provider credentials."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class CredentialCliPolicy:
|
|
10
|
+
required_providers: frozenset[str]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class CredentialCliPorts:
|
|
15
|
+
normalize_provider: Callable[[str], str]
|
|
16
|
+
load_config: Callable[[], dict[str, Any]]
|
|
17
|
+
key_count: Callable[[str, dict[str, Any]], int]
|
|
18
|
+
primary_key: Callable[[str, dict[str, Any]], str]
|
|
19
|
+
mask: Callable[[str | None], str]
|
|
20
|
+
fingerprint: Callable[[str | None], str]
|
|
21
|
+
clear_requested: Callable[[Any], bool]
|
|
22
|
+
clear: Callable[[str], list[str]]
|
|
23
|
+
store_input: Callable[[str, str], list[str]]
|
|
24
|
+
store_many: Callable[[str, list[str]], list[str]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class CredentialCliIO:
|
|
29
|
+
stdin_isatty: Callable[[], bool]
|
|
30
|
+
prompt_secret: Callable[[str], str]
|
|
31
|
+
write_line: Callable[[str], None]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class CredentialCliController:
|
|
36
|
+
policy: CredentialCliPolicy
|
|
37
|
+
ports: CredentialCliPorts
|
|
38
|
+
io: CredentialCliIO
|
|
39
|
+
|
|
40
|
+
def set_one(self, args: Any) -> None:
|
|
41
|
+
provider = self.ports.normalize_provider(args.provider)
|
|
42
|
+
key = args.key.strip()
|
|
43
|
+
if not key:
|
|
44
|
+
raise SystemExit("No key provided; unchanged.")
|
|
45
|
+
self._write(self.ports.store_input(provider, key))
|
|
46
|
+
|
|
47
|
+
def set_many(self, args: Any) -> None:
|
|
48
|
+
provider = self.ports.normalize_provider(args.provider)
|
|
49
|
+
raw = "\n".join(str(item) for item in getattr(args, "keys", []) if str(item).strip())
|
|
50
|
+
keys = self._parse_cli_keys(raw)
|
|
51
|
+
if not keys:
|
|
52
|
+
raise SystemExit("No API keys provided; unchanged.")
|
|
53
|
+
self._write(self.ports.store_many(provider, keys))
|
|
54
|
+
|
|
55
|
+
def manage(self, args: Any) -> None:
|
|
56
|
+
if not args.provider:
|
|
57
|
+
self._write_status()
|
|
58
|
+
return
|
|
59
|
+
provider = self.ports.normalize_provider(args.provider)
|
|
60
|
+
action = str(getattr(args, "action", "") or "").strip()
|
|
61
|
+
if self.ports.clear_requested(action):
|
|
62
|
+
self._write(self.ports.clear(provider))
|
|
63
|
+
return
|
|
64
|
+
if not self.io.stdin_isatty():
|
|
65
|
+
self.io.write_line("For security, do not paste API keys into Claude Code chat.")
|
|
66
|
+
self.io.write_line(f"Run this in the SSH terminal instead: ciel-runtimectl api-key {provider}")
|
|
67
|
+
return
|
|
68
|
+
key = self.io.prompt_secret(f"API key for {provider}: ").strip()
|
|
69
|
+
if not key:
|
|
70
|
+
raise SystemExit("No key entered; unchanged.")
|
|
71
|
+
self._write(self.ports.store_input(provider, key))
|
|
72
|
+
|
|
73
|
+
def _write_status(self) -> None:
|
|
74
|
+
config = self.ports.load_config()
|
|
75
|
+
self.io.write_line("API key status:")
|
|
76
|
+
for provider, provider_config in config["providers"].items():
|
|
77
|
+
count = self.ports.key_count(provider, provider_config)
|
|
78
|
+
if count > 1:
|
|
79
|
+
label = f"{count} keys (round-robin)"
|
|
80
|
+
elif count == 1:
|
|
81
|
+
label = "set"
|
|
82
|
+
else:
|
|
83
|
+
label = "missing" if provider in self.policy.required_providers else "not required"
|
|
84
|
+
primary = self.ports.primary_key(provider, provider_config)
|
|
85
|
+
suffix = (
|
|
86
|
+
f" (primary {self.ports.mask(primary)}; fp {self.ports.fingerprint(primary)})"
|
|
87
|
+
if count
|
|
88
|
+
else ""
|
|
89
|
+
)
|
|
90
|
+
self.io.write_line(f" {provider:<15} {label}{suffix}")
|
|
91
|
+
self.io.write_line("\nSet securely from terminal: ciel-runtimectl api-key anthropic")
|
|
92
|
+
self.io.write_line("Set multiple keys: ciel-runtimectl set-api-keys deepseek KEY1,KEY2")
|
|
93
|
+
self.io.write_line("For NVIDIA hosted, use: ciel-runtimectl api-key nvidia-hosted")
|
|
94
|
+
|
|
95
|
+
def _parse_cli_keys(self, raw: str) -> list[str]:
|
|
96
|
+
# store_many owns canonical parsing; preserve separate CLI empty-input validation.
|
|
97
|
+
return [item.strip() for item in raw.splitlines() if item.strip()]
|
|
98
|
+
|
|
99
|
+
def _write(self, lines: list[str]) -> None:
|
|
100
|
+
for line in lines:
|
|
101
|
+
self.io.write_line(line)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["CredentialCliController", "CredentialCliIO", "CredentialCliPolicy", "CredentialCliPorts"]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Credential persistence transactions for provider API keys."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from threading import Lock, RLock
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_dotenv_file(path: Path) -> dict[str, str]:
|
|
13
|
+
"""Read the simple KEY=VALUE format used by runtime-owned env files."""
|
|
14
|
+
|
|
15
|
+
environment: dict[str, str] = {}
|
|
16
|
+
if not path.exists():
|
|
17
|
+
return environment
|
|
18
|
+
for line in path.read_text(errors="ignore").splitlines():
|
|
19
|
+
line = line.strip()
|
|
20
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
21
|
+
continue
|
|
22
|
+
key, value = line.split("=", 1)
|
|
23
|
+
environment[key.strip()] = value.strip().strip("'\"")
|
|
24
|
+
return environment
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class CredentialPersistencePorts:
|
|
29
|
+
load_config: Callable[[], dict[str, Any]]
|
|
30
|
+
save_config: Callable[[dict[str, Any]], None]
|
|
31
|
+
clear_model_cache: Callable[[], None]
|
|
32
|
+
parse_keys: Callable[[Any], list[str]]
|
|
33
|
+
clear_requested: Callable[[Any], bool]
|
|
34
|
+
rotation_name: Callable[[str, dict[str, Any]], str]
|
|
35
|
+
error_text: Callable[[Any], bool] = lambda _value: False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ExternalCredentialPorts:
|
|
40
|
+
enabled: Callable[[str], bool]
|
|
41
|
+
store: Callable[[str], None]
|
|
42
|
+
clear: Callable[[], None]
|
|
43
|
+
has_key: Callable[[], bool]
|
|
44
|
+
normalize_provider_config: Callable[[dict[str, Any]], bool]
|
|
45
|
+
location: Any
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class CredentialPresentationPorts:
|
|
50
|
+
mask: Callable[[str | None], str]
|
|
51
|
+
fingerprint: Callable[[str | None], str]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CredentialRotationRepository:
|
|
55
|
+
def __init__(self, cursor: dict[str, int], lock: Lock | RLock) -> None:
|
|
56
|
+
self._cursor = cursor
|
|
57
|
+
self._lock = lock
|
|
58
|
+
|
|
59
|
+
def reset(self, name: str) -> None:
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._cursor.pop(name, None)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class EnvCredentialRepository:
|
|
66
|
+
"""Persist one credential in a chmod-protected dotenv file."""
|
|
67
|
+
|
|
68
|
+
path: Path
|
|
69
|
+
key_name: str
|
|
70
|
+
defaults: dict[str, str]
|
|
71
|
+
read_env_file: Callable[[Path], dict[str, str]]
|
|
72
|
+
parse_values: Callable[[Any], list[str]]
|
|
73
|
+
|
|
74
|
+
def store(self, value: str) -> None:
|
|
75
|
+
environment = self.read_env_file(self.path)
|
|
76
|
+
environment[self.key_name] = value
|
|
77
|
+
for name, default in self.defaults.items():
|
|
78
|
+
environment.setdefault(name, default)
|
|
79
|
+
self._write(environment)
|
|
80
|
+
|
|
81
|
+
def clear(self) -> None:
|
|
82
|
+
environment = self.read_env_file(self.path)
|
|
83
|
+
if self.key_name not in environment:
|
|
84
|
+
return
|
|
85
|
+
environment.pop(self.key_name, None)
|
|
86
|
+
self._write(environment)
|
|
87
|
+
|
|
88
|
+
def has_key(self) -> bool:
|
|
89
|
+
return bool(self.parse_values(self.read_env_file(self.path).get(self.key_name)))
|
|
90
|
+
|
|
91
|
+
def _write(self, environment: dict[str, str]) -> None:
|
|
92
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
self.path.write_text("".join(f"{key}={value}\n" for key, value in environment.items()))
|
|
94
|
+
os.chmod(self.path, 0o600)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def nvidia_env_credential_repository(
|
|
98
|
+
path: Path,
|
|
99
|
+
read_env_file: Callable[[Path], dict[str, str]],
|
|
100
|
+
parse_values: Callable[[Any], list[str]],
|
|
101
|
+
base_url: str,
|
|
102
|
+
) -> EnvCredentialRepository:
|
|
103
|
+
return EnvCredentialRepository(
|
|
104
|
+
path=path,
|
|
105
|
+
key_name="NVIDIA_API_KEY",
|
|
106
|
+
defaults={
|
|
107
|
+
"NVIDIA_BASE_URL": base_url,
|
|
108
|
+
"PROXY_HOST": "127.0.0.1",
|
|
109
|
+
"PROXY_PORT": "8788",
|
|
110
|
+
"STORAGE_ENGINE": "sqlite",
|
|
111
|
+
},
|
|
112
|
+
read_env_file=read_env_file,
|
|
113
|
+
parse_values=parse_values,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class CredentialManagementService:
|
|
119
|
+
persistence: CredentialPersistencePorts
|
|
120
|
+
external: ExternalCredentialPorts
|
|
121
|
+
presentation: CredentialPresentationPorts
|
|
122
|
+
rotation: CredentialRotationRepository
|
|
123
|
+
config_location: Any
|
|
124
|
+
|
|
125
|
+
def store_one(self, provider: str, key: str) -> list[str]:
|
|
126
|
+
if self.persistence.clear_requested(key):
|
|
127
|
+
return self.clear(provider)
|
|
128
|
+
config = self.persistence.load_config()
|
|
129
|
+
provider_config = config["providers"][provider]
|
|
130
|
+
if self.external.enabled(provider):
|
|
131
|
+
self.external.store(key)
|
|
132
|
+
provider_config.pop("api_keys", None)
|
|
133
|
+
if self.external.normalize_provider_config(provider_config):
|
|
134
|
+
self.persistence.save_config(config)
|
|
135
|
+
location = self.external.location
|
|
136
|
+
else:
|
|
137
|
+
provider_config["api_key"] = key
|
|
138
|
+
provider_config.pop("api_keys", None)
|
|
139
|
+
self.persistence.save_config(config)
|
|
140
|
+
location = self.config_location
|
|
141
|
+
self.persistence.clear_model_cache()
|
|
142
|
+
return [
|
|
143
|
+
f"Stored API key for {provider}.",
|
|
144
|
+
f"Saved: {self.presentation.mask(key)}; "
|
|
145
|
+
f"fp {self.presentation.fingerprint(key)} in {location}",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
def clear(self, provider: str) -> list[str]:
|
|
149
|
+
config = self.persistence.load_config()
|
|
150
|
+
providers = config["providers"]
|
|
151
|
+
other_key_fields = self._snapshot_other_provider_keys(providers, provider)
|
|
152
|
+
provider_config = providers[provider]
|
|
153
|
+
had_key = bool(
|
|
154
|
+
self.persistence.parse_keys(provider_config.get("api_key"))
|
|
155
|
+
or self.persistence.parse_keys(provider_config.get("api_keys"))
|
|
156
|
+
)
|
|
157
|
+
provider_config.pop("api_key", None)
|
|
158
|
+
provider_config.pop("api_keys", None)
|
|
159
|
+
if self.external.enabled(provider):
|
|
160
|
+
had_key = had_key or self.external.has_key()
|
|
161
|
+
self.external.clear()
|
|
162
|
+
self.external.normalize_provider_config(provider_config)
|
|
163
|
+
self._restore_other_provider_keys(providers, other_key_fields)
|
|
164
|
+
self.persistence.save_config(config)
|
|
165
|
+
self.persistence.clear_model_cache()
|
|
166
|
+
self.rotation.reset(self.persistence.rotation_name(provider, provider_config))
|
|
167
|
+
if had_key:
|
|
168
|
+
return [f"Cleared stored API key(s) for {provider}. Other providers unchanged."]
|
|
169
|
+
return [f"No stored API key(s) for {provider}; other providers unchanged."]
|
|
170
|
+
|
|
171
|
+
def store_many(self, provider: str, keys: list[str]) -> list[str]:
|
|
172
|
+
parsed = self.persistence.parse_keys(keys)
|
|
173
|
+
if len(parsed) == 1 and self.persistence.clear_requested(parsed[0]):
|
|
174
|
+
return self.clear(provider)
|
|
175
|
+
if not parsed:
|
|
176
|
+
raise SystemExit("No API keys provided; unchanged.")
|
|
177
|
+
config = self.persistence.load_config()
|
|
178
|
+
provider_config = config["providers"][provider]
|
|
179
|
+
provider_config["api_key"] = parsed[0]
|
|
180
|
+
if len(parsed) > 1:
|
|
181
|
+
provider_config["api_keys"] = parsed
|
|
182
|
+
else:
|
|
183
|
+
provider_config.pop("api_keys", None)
|
|
184
|
+
if self.external.enabled(provider):
|
|
185
|
+
self.external.store(parsed[0])
|
|
186
|
+
self.external.normalize_provider_config(provider_config)
|
|
187
|
+
self.persistence.save_config(config)
|
|
188
|
+
self.persistence.clear_model_cache()
|
|
189
|
+
self.rotation.reset(self.persistence.rotation_name(provider, provider_config))
|
|
190
|
+
return [
|
|
191
|
+
f"Stored {len(parsed)} API key{'s' if len(parsed) != 1 else ''} for {provider}.",
|
|
192
|
+
f"Round-robin: {'enabled' if len(parsed) > 1 else 'disabled'}",
|
|
193
|
+
f"Primary: {self.presentation.mask(parsed[0])}; "
|
|
194
|
+
f"fp {self.presentation.fingerprint(parsed[0])}",
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
def store_input(self, provider: str, raw_value: str) -> list[str]:
|
|
198
|
+
if self.persistence.clear_requested(raw_value):
|
|
199
|
+
return self.clear(provider)
|
|
200
|
+
keys = self.persistence.parse_keys(raw_value)
|
|
201
|
+
# Refuse to persist a "key" that is actually an upstream error string
|
|
202
|
+
# (operator 2026-07-29: the ollama-cloud api_key held the literal text
|
|
203
|
+
# of a 504 URLError; every chat call then failed 401 while the
|
|
204
|
+
# unauthenticated /api/tags kept working, masking the corruption).
|
|
205
|
+
rejected = [key for key in keys if self.persistence.error_text(key)]
|
|
206
|
+
if rejected:
|
|
207
|
+
raise SystemExit(
|
|
208
|
+
"Refusing to store an API key that looks like an error message "
|
|
209
|
+
f"({len(rejected)} item(s) contain error prose). Paste the key "
|
|
210
|
+
"itself, not the error output. Unchanged."
|
|
211
|
+
)
|
|
212
|
+
if len(keys) > 1:
|
|
213
|
+
return self.store_many(provider, keys)
|
|
214
|
+
if len(keys) == 1:
|
|
215
|
+
return self.store_one(provider, keys[0])
|
|
216
|
+
raise SystemExit("No API key provided; unchanged.")
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _snapshot_other_provider_keys(
|
|
220
|
+
providers: dict[str, Any], target_provider: str
|
|
221
|
+
) -> dict[str, tuple[bool, Any, bool, Any]]:
|
|
222
|
+
snapshots: dict[str, tuple[bool, Any, bool, Any]] = {}
|
|
223
|
+
for name, provider_config in providers.items():
|
|
224
|
+
if name == target_provider or not isinstance(provider_config, dict):
|
|
225
|
+
continue
|
|
226
|
+
snapshots[name] = (
|
|
227
|
+
"api_key" in provider_config,
|
|
228
|
+
deepcopy(provider_config.get("api_key")),
|
|
229
|
+
"api_keys" in provider_config,
|
|
230
|
+
deepcopy(provider_config.get("api_keys")),
|
|
231
|
+
)
|
|
232
|
+
return snapshots
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _restore_other_provider_keys(
|
|
236
|
+
providers: dict[str, Any], snapshots: dict[str, tuple[bool, Any, bool, Any]]
|
|
237
|
+
) -> None:
|
|
238
|
+
for name, (has_key, key, has_keys, keys) in snapshots.items():
|
|
239
|
+
provider_config = providers.get(name)
|
|
240
|
+
if not isinstance(provider_config, dict):
|
|
241
|
+
continue
|
|
242
|
+
if has_key:
|
|
243
|
+
provider_config["api_key"] = key
|
|
244
|
+
else:
|
|
245
|
+
provider_config.pop("api_key", None)
|
|
246
|
+
if has_keys:
|
|
247
|
+
provider_config["api_keys"] = keys
|
|
248
|
+
else:
|
|
249
|
+
provider_config.pop("api_keys", None)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = [
|
|
253
|
+
"CredentialManagementService",
|
|
254
|
+
"CredentialPersistencePorts",
|
|
255
|
+
"CredentialPresentationPorts",
|
|
256
|
+
"CredentialRotationRepository",
|
|
257
|
+
"EnvCredentialRepository",
|
|
258
|
+
"ExternalCredentialPorts",
|
|
259
|
+
"nvidia_env_credential_repository",
|
|
260
|
+
"parse_dotenv_file",
|
|
261
|
+
]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Credential resolution ports for API keys, OAuth pass-through, and future stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Mapping, Protocol
|
|
9
|
+
|
|
10
|
+
from ciel_runtime_support.architecture import ProviderConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
API_KEY_CLEAR_TOKENS = frozenset({"clear", "unset", "none", "null", "off", "delete", "remove"})
|
|
14
|
+
|
|
15
|
+
# Persisted credential hygiene: these substrings mean the stored "key" is not a
|
|
16
|
+
# key at all but an upstream error string that was pasted or written into the
|
|
17
|
+
# credential field (operator 2026-07-29: the ollama-cloud api_key held the
|
|
18
|
+
# literal text "504 URLError: <urlopen error EOF occurred in violation of
|
|
19
|
+
# protocol (_ssl.c:2406" — every chat call then failed 401 while /api/tags,
|
|
20
|
+
# which is unauthenticated, kept working and made the key look valid).
|
|
21
|
+
SECRET_ERROR_MARKERS = (
|
|
22
|
+
"urlerror",
|
|
23
|
+
"httperror",
|
|
24
|
+
"http error",
|
|
25
|
+
"<urlopen error",
|
|
26
|
+
"eof occurred in violation of protocol",
|
|
27
|
+
"traceback (most recent call last)",
|
|
28
|
+
"connection refused",
|
|
29
|
+
"connection reset",
|
|
30
|
+
"timed out",
|
|
31
|
+
"unauthorized",
|
|
32
|
+
"401 client error",
|
|
33
|
+
"403 client error",
|
|
34
|
+
"404 client error",
|
|
35
|
+
"500 internal server error",
|
|
36
|
+
"502 bad gateway",
|
|
37
|
+
"503 service unavailable",
|
|
38
|
+
"504 gateway",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Structural shapes shared by every common API key format (Anthropic sk-ant-*,
|
|
42
|
+
# Ollama, DeepSeek, OpenRouter sk-or-*, etc.): single line, no whitespace, no
|
|
43
|
+
# error prose. A credential is a token, not a sentence.
|
|
44
|
+
_KEY_PLAUSIBLE_RE = re.compile(r"^\S{8,512}$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def looks_like_error_text(value: Any) -> bool:
|
|
48
|
+
text = str(value or "").strip().lower()
|
|
49
|
+
if not text:
|
|
50
|
+
return False
|
|
51
|
+
return any(marker in text for marker in SECRET_ERROR_MARKERS)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def plausible_api_key(value: Any) -> bool:
|
|
55
|
+
"""Minimal structural sanity check: one non-space token, no error prose."""
|
|
56
|
+
|
|
57
|
+
text = str(value or "").strip()
|
|
58
|
+
if not _KEY_PLAUSIBLE_RE.match(text):
|
|
59
|
+
return False
|
|
60
|
+
return not looks_like_error_text(text)
|
|
61
|
+
SECRET_TEXT_PATTERNS = (
|
|
62
|
+
re.compile(r"ak_key_[A-Za-z0-9_-]+_secret_[A-Za-z0-9_-]+"),
|
|
63
|
+
re.compile(r"(AINET_API_KEY\s*=\s*)(\S+)", re.IGNORECASE),
|
|
64
|
+
re.compile(r"(Authorization\s*:\s*Bearer\s+)(\S+)", re.IGNORECASE),
|
|
65
|
+
re.compile(r"(token=)(ak_key_[A-Za-z0-9_-]+_secret_[A-Za-z0-9_-]+)", re.IGNORECASE),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def meaningful_key_value(value: Any) -> bool:
|
|
70
|
+
text = str(value or "").strip()
|
|
71
|
+
return bool(text and text not in {"dummy", "not-used", "ollama"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def api_key_clear_requested(value: Any) -> bool:
|
|
75
|
+
return str(value or "").strip().lower() in API_KEY_CLEAR_TOKENS
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_api_key_list(value: Any) -> list[str]:
|
|
79
|
+
"""Parse pasted, delimited, and soft-wrapped key input deterministically."""
|
|
80
|
+
|
|
81
|
+
if value is None:
|
|
82
|
+
return []
|
|
83
|
+
raw_items: list[Any]
|
|
84
|
+
if isinstance(value, (list, tuple, set)):
|
|
85
|
+
raw_items = [key for item in value for key in parse_api_key_list(item)]
|
|
86
|
+
else:
|
|
87
|
+
text = str(value)
|
|
88
|
+
if re.search(r"[,;]", text):
|
|
89
|
+
raw_items = []
|
|
90
|
+
for field in re.split(r"[,;]+", text):
|
|
91
|
+
current = ""
|
|
92
|
+
for line in str(field).splitlines() or [str(field)]:
|
|
93
|
+
if not line.strip():
|
|
94
|
+
continue
|
|
95
|
+
if current and line[:1].isspace():
|
|
96
|
+
current += line.strip()
|
|
97
|
+
else:
|
|
98
|
+
if current:
|
|
99
|
+
raw_items.append(current)
|
|
100
|
+
current = line.strip()
|
|
101
|
+
if current:
|
|
102
|
+
raw_items.append(current)
|
|
103
|
+
else:
|
|
104
|
+
raw_items = re.split(r"[\r\n]+", text)
|
|
105
|
+
return list(
|
|
106
|
+
dict.fromkeys(
|
|
107
|
+
key
|
|
108
|
+
for item in raw_items
|
|
109
|
+
if meaningful_key_value(key := str(item or "").strip())
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def mask_secret(value: str | None) -> str:
|
|
115
|
+
text = value or ""
|
|
116
|
+
if not text:
|
|
117
|
+
return "not set"
|
|
118
|
+
if len(text) <= 8:
|
|
119
|
+
return "*" * len(text)
|
|
120
|
+
return f"{text[:4]}...{text[-4:]}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def secret_fingerprint(value: str | None, length: int = 12) -> str:
|
|
124
|
+
text = value or ""
|
|
125
|
+
if not text:
|
|
126
|
+
return "-"
|
|
127
|
+
digest = hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
|
128
|
+
return digest[: max(4, length)]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def redact_sensitive_text(text: str) -> str:
|
|
132
|
+
redacted = SECRET_TEXT_PATTERNS[0].sub(lambda match: mask_secret(match.group(0)), text)
|
|
133
|
+
for pattern in SECRET_TEXT_PATTERNS[1:]:
|
|
134
|
+
redacted = pattern.sub(
|
|
135
|
+
lambda match: f"{match.group(1)}{mask_secret(match.group(2))}",
|
|
136
|
+
redacted,
|
|
137
|
+
)
|
|
138
|
+
return redacted
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def redact_sensitive_obj(value: Any) -> Any:
|
|
142
|
+
if isinstance(value, str):
|
|
143
|
+
return redact_sensitive_text(value)
|
|
144
|
+
if isinstance(value, list):
|
|
145
|
+
return [redact_sensitive_obj(item) for item in value]
|
|
146
|
+
if isinstance(value, dict):
|
|
147
|
+
return {
|
|
148
|
+
key: mask_secret(str(item))
|
|
149
|
+
if str(key).lower() in {"api_key", "api_keys", "apikey", "token", "authorization", "bearer_token"}
|
|
150
|
+
else redact_sensitive_obj(item)
|
|
151
|
+
for key, item in value.items()
|
|
152
|
+
}
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def provider_config_api_keys(
|
|
157
|
+
pcfg: Mapping[str, Any],
|
|
158
|
+
supplemental_keys: Any = (),
|
|
159
|
+
) -> list[str]:
|
|
160
|
+
keys = [
|
|
161
|
+
*parse_api_key_list(supplemental_keys),
|
|
162
|
+
*parse_api_key_list(pcfg.get("api_keys")),
|
|
163
|
+
*parse_api_key_list(pcfg.get("api_key")),
|
|
164
|
+
]
|
|
165
|
+
return list(dict.fromkeys(keys))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def provider_contract_config(
|
|
169
|
+
provider: str,
|
|
170
|
+
pcfg: Mapping[str, Any],
|
|
171
|
+
api_keys: list[str] | tuple[str, ...],
|
|
172
|
+
) -> ProviderConfig:
|
|
173
|
+
return ProviderConfig(
|
|
174
|
+
name=provider,
|
|
175
|
+
base_url=str(pcfg.get("base_url") or ""),
|
|
176
|
+
model=str(pcfg.get("current_model") or pcfg.get("model") or ""),
|
|
177
|
+
api_keys=tuple(api_keys),
|
|
178
|
+
options=pcfg,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True, slots=True)
|
|
183
|
+
class CredentialContext:
|
|
184
|
+
provider: str
|
|
185
|
+
api_key: str = ""
|
|
186
|
+
inbound_headers: Any | None = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True, slots=True)
|
|
190
|
+
class ResolvedCredential:
|
|
191
|
+
source: str
|
|
192
|
+
headers: Mapping[str, str]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class CredentialSource(Protocol):
|
|
196
|
+
def resolve(self, context: CredentialContext) -> ResolvedCredential | None: ...
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True)
|
|
200
|
+
class ApiKeyCredentialSource:
|
|
201
|
+
header: str = "x-api-key"
|
|
202
|
+
|
|
203
|
+
def resolve(self, context: CredentialContext) -> ResolvedCredential | None:
|
|
204
|
+
key = str(context.api_key or "").strip()
|
|
205
|
+
return ResolvedCredential("api_key", {self.header: key}) if key else None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True, slots=True)
|
|
209
|
+
class InboundHeaderCredentialSource:
|
|
210
|
+
"""Resolve credentials from an explicit inbound authentication allowlist."""
|
|
211
|
+
|
|
212
|
+
allowed_headers: tuple[str, ...]
|
|
213
|
+
required_any: tuple[str, ...] = ("authorization", "x-api-key")
|
|
214
|
+
|
|
215
|
+
def resolve(self, context: CredentialContext) -> ResolvedCredential | None:
|
|
216
|
+
inbound = context.inbound_headers
|
|
217
|
+
if inbound is None:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
inbound_by_name = {
|
|
221
|
+
str(raw_name).lower(): raw_value
|
|
222
|
+
for raw_name, raw_value in inbound.items()
|
|
223
|
+
}
|
|
224
|
+
except Exception:
|
|
225
|
+
inbound_by_name = {}
|
|
226
|
+
headers: dict[str, str] = {}
|
|
227
|
+
for name in self.allowed_headers:
|
|
228
|
+
try:
|
|
229
|
+
value = inbound.get(name)
|
|
230
|
+
except Exception:
|
|
231
|
+
value = None
|
|
232
|
+
if value is None:
|
|
233
|
+
value = inbound_by_name.get(name.lower())
|
|
234
|
+
if value:
|
|
235
|
+
headers[name] = str(value)
|
|
236
|
+
if not any(headers.get(name) for name in self.required_any):
|
|
237
|
+
return None
|
|
238
|
+
return ResolvedCredential("inbound", headers)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class CredentialChain:
|
|
242
|
+
"""Chain of Responsibility: first available credential wins."""
|
|
243
|
+
|
|
244
|
+
def __init__(self, *sources: CredentialSource) -> None:
|
|
245
|
+
self._sources = sources
|
|
246
|
+
|
|
247
|
+
def resolve(self, context: CredentialContext) -> ResolvedCredential | None:
|
|
248
|
+
for source in self._sources:
|
|
249
|
+
if credential := source.resolve(context):
|
|
250
|
+
return credential
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
ANTHROPIC_INBOUND_CREDENTIAL_SOURCE = InboundHeaderCredentialSource(
|
|
255
|
+
allowed_headers=(
|
|
256
|
+
"authorization",
|
|
257
|
+
"x-api-key",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def resolve_anthropic_credentials(
|
|
263
|
+
api_key: str,
|
|
264
|
+
inbound_headers: Any | None,
|
|
265
|
+
) -> ResolvedCredential | None:
|
|
266
|
+
return CredentialChain(
|
|
267
|
+
ApiKeyCredentialSource(),
|
|
268
|
+
ANTHROPIC_INBOUND_CREDENTIAL_SOURCE,
|
|
269
|
+
).resolve(CredentialContext("anthropic", api_key, inbound_headers))
|