pythinker-code 0.60.0__py3-none-any.whl → 0.62.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.
- pythinker_code/CHANGELOG.md +32 -0
- pythinker_code/auth/__init__.py +8 -0
- pythinker_code/auth/copilot.py +305 -0
- pythinker_code/auth/digitalocean.py +245 -0
- pythinker_code/auth/models_dev.py +454 -0
- pythinker_code/auth/oauth.py +697 -75
- pythinker_code/auth/oauth_flows.py +753 -0
- pythinker_code/auth/opencode_go.py +31 -53
- pythinker_code/auth/platforms.py +142 -46
- pythinker_code/auth/snowflake.py +371 -0
- pythinker_code/auth/xai.py +340 -0
- pythinker_code/cli/__init__.py +122 -7
- pythinker_code/config.py +30 -11
- pythinker_code/subagents/runner.py +3 -0
- pythinker_code/tools/lsp/tool.py +4 -4
- pythinker_code/ui/shell/__init__.py +133 -92
- pythinker_code/ui/shell/command_runner.py +135 -0
- pythinker_code/ui/shell/oauth.py +78 -7
- pythinker_code/ui/shell/prompt.py +1046 -2411
- pythinker_code/ui/shell/prompting/__init__.py +67 -0
- pythinker_code/ui/shell/prompting/clipboard.py +113 -0
- pythinker_code/ui/shell/prompting/completion/__init__.py +43 -0
- pythinker_code/ui/shell/prompting/completion/context.py +278 -0
- pythinker_code/ui/shell/prompting/completion/menus.py +687 -0
- pythinker_code/ui/shell/prompting/completion/slash.py +350 -0
- pythinker_code/ui/shell/prompting/completion/workspace.py +570 -0
- pythinker_code/ui/shell/prompting/config.py +55 -0
- pythinker_code/ui/shell/prompting/footer.py +126 -0
- pythinker_code/ui/shell/prompting/frame.py +147 -0
- pythinker_code/ui/shell/prompting/git_status.py +407 -0
- pythinker_code/ui/shell/prompting/history.py +276 -0
- pythinker_code/ui/shell/prompting/keybindings.py +420 -0
- pythinker_code/ui/shell/prompting/lifecycle.py +77 -0
- pythinker_code/ui/shell/prompting/renderer.py +88 -0
- pythinker_code/ui/shell/prompting/state.py +322 -0
- pythinker_code/ui/shell/prompting/toasts.py +182 -0
- pythinker_code/ui/shell/replay.py +2 -1
- pythinker_code/ui/shell/slash.py +46 -0
- pythinker_code/ui/shell/statusline.py +18 -6
- pythinker_code/ui/shell/tool_renderers/agent.py +144 -122
- pythinker_code/ui/shell/update.py +9 -0
- pythinker_code/ui/shell/visualize/_blocks.py +384 -29
- pythinker_code/ui/shell/visualize/_interactive.py +73 -19
- pythinker_code/ui/shell/visualize/_live_view.py +208 -41
- pythinker_code/ui/theme/registry.py +7 -3
- pythinker_code/utils/io.py +60 -9
- pythinker_code/web/api/sessions.py +2 -1
- pythinker_code/web/static/assets/architecture-7EHR7CIX-7yUuXz_6.js +1 -0
- pythinker_code/web/static/assets/{architectureDiagram-3BPJPVTR-CQB35YNL.js → architectureDiagram-3BPJPVTR-kN5y1Jpf.js} +1 -1
- pythinker_code/web/static/assets/{blockDiagram-GPEHLZMM-Dzn-MvOQ.js → blockDiagram-GPEHLZMM-ay8f3ouR.js} +1 -1
- pythinker_code/web/static/assets/{bootstrap-Cg8OxEIk.js → bootstrap-S1KM-DEW.js} +5 -5
- pythinker_code/web/static/assets/{c4Diagram-AAUBKEIU-DOdn3M8j.js → c4Diagram-AAUBKEIU-CLTHO8TB.js} +1 -1
- pythinker_code/web/static/assets/channel-B8FBABBt.js +1 -0
- pythinker_code/web/static/assets/{chunk-2J33WTMH-D8y8jM6G.js → chunk-2J33WTMH-BVQ8lE9G.js} +1 -1
- pythinker_code/web/static/assets/{chunk-3OPIFGDE-CISKfk8B.js → chunk-3OPIFGDE-CWiguBMG.js} +1 -1
- pythinker_code/web/static/assets/{chunk-5ZQYHXKU-Cmu2q8Rh.js → chunk-5ZQYHXKU-B9Zlc-aT.js} +1 -1
- pythinker_code/web/static/assets/{chunk-727SXJPM-y8Ue8Q-r.js → chunk-727SXJPM-MImcCexG.js} +1 -1
- pythinker_code/web/static/assets/{chunk-AQP2D5EJ-BcacTxko.js → chunk-AQP2D5EJ-BOePrMEY.js} +1 -1
- pythinker_code/web/static/assets/{chunk-CSCIHK7Q-DmRHMJb7.js → chunk-CSCIHK7Q-BgUUe_OK.js} +1 -1
- pythinker_code/web/static/assets/{chunk-JAPRZBRM-CUxWX_xo.js → chunk-JAPRZBRM-CZdsfD3G.js} +4 -4
- pythinker_code/web/static/assets/{chunk-KSCS5N6A-DhJnqsdL.js → chunk-KSCS5N6A-Bt7bPLOL.js} +1 -1
- pythinker_code/web/static/assets/{chunk-L5ZTLDWV-NFkBi_Vm.js → chunk-L5ZTLDWV-BKkLltw7.js} +1 -1
- pythinker_code/web/static/assets/{chunk-LZXEDZCA-2s03CWIo.js → chunk-LZXEDZCA-DL8eQYxM.js} +2 -2
- pythinker_code/web/static/assets/{chunk-ND2GUHAM-M6TLIhoY.js → chunk-ND2GUHAM-BRB3B1Sj.js} +1 -1
- pythinker_code/web/static/assets/{chunk-NZK2D7GU-B636uOYb.js → chunk-NZK2D7GU-CEo2-R5k.js} +1 -1
- pythinker_code/web/static/assets/{chunk-O5CBEL6O-Dz7EQRTV.js → chunk-O5CBEL6O-CsFvKDJU.js} +1 -1
- pythinker_code/web/static/assets/{chunk-WU5MYG2G-BeEJ80kO.js → chunk-WU5MYG2G-a2xnNUhR.js} +1 -1
- pythinker_code/web/static/assets/classDiagram-4FO5ZUOK-D-cz27-m.js +1 -0
- pythinker_code/web/static/assets/classDiagram-v2-Q7XG4LA2-D-cz27-m.js +1 -0
- pythinker_code/web/static/assets/{code-block-IT6T5CEO-BC_pN-Yw.js → code-block-IT6T5CEO-2XeVzOg8.js} +1 -1
- pythinker_code/web/static/assets/{dagre-BM42HDAG-DvDDgf8V.js → dagre-BM42HDAG-Bpibp3hN.js} +1 -1
- pythinker_code/web/static/assets/{diagram-2AECGRRQ-mI4kgcAO.js → diagram-2AECGRRQ-Qk5WcKlr.js} +1 -1
- pythinker_code/web/static/assets/{diagram-5GNKFQAL-CPqhJA-b.js → diagram-5GNKFQAL-CZCQSYzJ.js} +1 -1
- pythinker_code/web/static/assets/{diagram-KO2AKTUF-okeubuOf.js → diagram-KO2AKTUF-C8AEI5hM.js} +1 -1
- pythinker_code/web/static/assets/{diagram-LMA3HP47-GpyhYIFC.js → diagram-LMA3HP47-BaLiJz83.js} +1 -1
- pythinker_code/web/static/assets/{diagram-OG6HWLK6-mspbAyR1.js → diagram-OG6HWLK6--4Nbejfz.js} +1 -1
- pythinker_code/web/static/assets/{dist-BRJOHEfJ.js → dist-ClYYVNiW.js} +1 -1
- pythinker_code/web/static/assets/{erDiagram-TEJ5UH35-D3-qDHzY.js → erDiagram-TEJ5UH35-B3CW9ZRe.js} +1 -1
- pythinker_code/web/static/assets/eventmodeling-FCH6USID-DLGeZepM.js +1 -0
- pythinker_code/web/static/assets/{flowDiagram-I6XJVG4X-CLGi2lTa.js → flowDiagram-I6XJVG4X-L2db82wq.js} +1 -1
- pythinker_code/web/static/assets/{ganttDiagram-6RSMTGT7-B3-QWCmD.js → ganttDiagram-6RSMTGT7-GW3eNThU.js} +1 -1
- pythinker_code/web/static/assets/{gitGraph-WXDBUCRP-VdtYpDJ_.js → gitGraph-WXDBUCRP-BHqLWd3S.js} +1 -1
- pythinker_code/web/static/assets/{gitGraphDiagram-PVQCEYII-CqoCv1c4.js → gitGraphDiagram-PVQCEYII-E0CFOqaX.js} +1 -1
- pythinker_code/web/static/assets/{index-Bwb07Fmw.js → index-1B9OiEzj.js} +2 -2
- pythinker_code/web/static/assets/{info-J43DQDTF-DJ8lwJdB.js → info-J43DQDTF-vcEEQm0V.js} +1 -1
- pythinker_code/web/static/assets/{infoDiagram-5YYISTIA-dsAKLNGZ.js → infoDiagram-5YYISTIA-BpmiyoRM.js} +1 -1
- pythinker_code/web/static/assets/{ishikawaDiagram-YF4QCWOH-Qhh01wyO.js → ishikawaDiagram-YF4QCWOH-D23o96ks.js} +1 -1
- pythinker_code/web/static/assets/{journeyDiagram-JHISSGLW-2b0X6J4l.js → journeyDiagram-JHISSGLW-DzEX_rL2.js} +1 -1
- pythinker_code/web/static/assets/{kanban-definition-UN3LZRKU-P70tWcUV.js → kanban-definition-UN3LZRKU-BfaBNxdU.js} +1 -1
- pythinker_code/web/static/assets/{line-LvudDuKP.js → line-D1x4nBuC.js} +1 -1
- pythinker_code/web/static/assets/mermaid-VLURNSYL-DnHrV-Cw.js +1 -0
- pythinker_code/web/static/assets/{mermaid-parser.core-BXce5aFh.js → mermaid-parser.core-Dg4dG-gq.js} +2 -2
- pythinker_code/web/static/assets/{mermaid.core-DAmC1ASV.js → mermaid.core-BxNHwSq2.js} +3 -3
- pythinker_code/web/static/assets/{mindmap-definition-RKZ34NQL-Cdyk5D3F.js → mindmap-definition-RKZ34NQL-D0Usem3o.js} +1 -1
- pythinker_code/web/static/assets/{packet-YPE3B663-BfGyictS.js → packet-YPE3B663-QyGpgkjT.js} +1 -1
- pythinker_code/web/static/assets/{pie-LRSECV5Y-B5QwRxIa.js → pie-LRSECV5Y-DaaSWw8w.js} +1 -1
- pythinker_code/web/static/assets/{pieDiagram-4H26LBE5-DjZjVQAH.js → pieDiagram-4H26LBE5-Bn6b-msm.js} +1 -1
- pythinker_code/web/static/assets/{quadrantDiagram-W4KKPZXB-BFdmBdAa.js → quadrantDiagram-W4KKPZXB-DylkTLVn.js} +1 -1
- pythinker_code/web/static/assets/{radar-GUYGQ44K-Del6SV-D.js → radar-GUYGQ44K-B5LtHmKt.js} +1 -1
- pythinker_code/web/static/assets/{requirementDiagram-4Y6WPE33-CbKF62IW.js → requirementDiagram-4Y6WPE33-B7P2ssJV.js} +1 -1
- pythinker_code/web/static/assets/{sankeyDiagram-5OEKKPKP-cKWw70Mq.js → sankeyDiagram-5OEKKPKP-CNTn-n2b.js} +1 -1
- pythinker_code/web/static/assets/{sequenceDiagram-3UESZ5HK-DowvJHGL.js → sequenceDiagram-3UESZ5HK-CF5Xisyl.js} +1 -1
- pythinker_code/web/static/assets/{stateDiagram-AJRCARHV-DENCh7DG.js → stateDiagram-AJRCARHV-d8g3ltDG.js} +1 -1
- pythinker_code/web/static/assets/stateDiagram-v2-BHNVJYJU-D9zus-0d.js +1 -0
- pythinker_code/web/static/assets/{timeline-definition-PNZ67QCA-D7vrwE4p.js → timeline-definition-PNZ67QCA-Jv4dMAzr.js} +1 -1
- pythinker_code/web/static/assets/{treeView-BLDUP644-BkpWpdte.js → treeView-BLDUP644-C3BmP5d3.js} +1 -1
- pythinker_code/web/static/assets/{treemap-LRROVOQU-O0XS2jca.js → treemap-LRROVOQU-r12SIbVM.js} +1 -1
- pythinker_code/web/static/assets/{vennDiagram-CIIHVFJN-BtzihevZ.js → vennDiagram-CIIHVFJN-0fySknsk.js} +1 -1
- pythinker_code/web/static/assets/{wardley-L42UT6IY-CNG86R2O.js → wardley-L42UT6IY-WzZrzUpE.js} +1 -1
- pythinker_code/web/static/assets/{wardleyDiagram-YWT4CUSO-CQswyIqM.js → wardleyDiagram-YWT4CUSO-BE4m4b4-.js} +1 -1
- pythinker_code/web/static/assets/{xychartDiagram-2RQKCTM6-DVYTvk-7.js → xychartDiagram-2RQKCTM6-WKhXzzGu.js} +1 -1
- pythinker_code/web/static/index.html +1 -1
- pythinker_code/wire/types.py +2 -0
- {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/METADATA +22 -23
- {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/RECORD +119 -95
- pythinker_code/web/static/assets/architecture-7EHR7CIX-Sj7uuR-x.js +0 -1
- pythinker_code/web/static/assets/channel-5kwaSm7B.js +0 -1
- pythinker_code/web/static/assets/classDiagram-4FO5ZUOK-CWIwj2q3.js +0 -1
- pythinker_code/web/static/assets/classDiagram-v2-Q7XG4LA2-CWIwj2q3.js +0 -1
- pythinker_code/web/static/assets/eventmodeling-FCH6USID-VIVurDG_.js +0 -1
- pythinker_code/web/static/assets/mermaid-VLURNSYL-CQhaV3xy.js +0 -1
- pythinker_code/web/static/assets/stateDiagram-v2-BHNVJYJU-DqQMlVC4.js +0 -1
- {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/WHEEL +0 -0
- {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/entry_points.txt +0 -0
- {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/licenses/LICENSE +0 -0
- {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/licenses/NOTICE +0 -0
pythinker_code/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,38 @@ GitHub Releases page; `0.8.0` is the new starting line.
|
|
|
15
15
|
|
|
16
16
|
## Unreleased
|
|
17
17
|
|
|
18
|
+
## 0.62.0 (2026-07-22)
|
|
19
|
+
|
|
20
|
+
- Treat the repository-root `.agents/` directory as local agent configuration and keep it out of version control.
|
|
21
|
+
- Show an update notice when a newer Pythinker version becomes available mid-session.
|
|
22
|
+
|
|
23
|
+
## 0.61.0 (2026-07-22)
|
|
24
|
+
|
|
25
|
+
- Fixed reasoning summaries exposing Markdown delimiters and duplicate terminal rows after refocus, and redesigned agent progress (single `Agent` calls and parallel `RunAgents` fan-outs) as a compact, payload-free agent activity tree.
|
|
26
|
+
- Retain a non-streaming OpenAI Responses reasoning item's encrypted content when it carries no summary, matching the streaming path so the reasoning boundary stays replayable.
|
|
27
|
+
- Reduce the shell prompt session to a compatibility façade over deep `prompting/` modules (config, keybindings, completion menus, narrow shell-facing methods), with Unicode cell-width coverage and documented module ownership in the architecture guide.
|
|
28
|
+
- Render image/audio/video and unknown content parts as payload-free labels in the live view, roll nested subagent activity (up to 16 levels) under the correct root tool card, and stop provider-remapped tool results from starting replay user turns.
|
|
29
|
+
- **Behavior change:** `!` shell commands now run through the detected configured shell (`<shell> -c`, PowerShell `-command` on Windows) instead of the implicit `/bin/sh`/`cmd.exe`, with separate 1 MiB stdout/stderr caps and cancellation cleanup; existing `cmd.exe`-syntax commands on Windows may need updating.
|
|
30
|
+
- Distinguish background auto-trigger grace expiry from user input activity with typed prompt events, and bound streamed tool-argument label scans to 1 KiB growth boundaries.
|
|
31
|
+
- Scope prompt Git status, toasts, history, and clipboard to each shell session with a new `/prompt-history status|clear` command, bounded locked history storage, and cross-session isolation.
|
|
32
|
+
- Unify the shell footer behind one per-frame view model shared by the legacy and card renderers, and key theme style-resolver caching by terminal capabilities.
|
|
33
|
+
- Fix a rare queued-follow-up "ghost card": echoing a drained queued command now commits through the scrollback handoff (which hides the input card before the terminal teardown erases the prompt), so the input-card border can no longer fossilize into scrollback above the echoed command under heavy load.
|
|
34
|
+
- Freeze the public shell prompt compatibility contract with constructor and rendering coverage.
|
|
35
|
+
- Unify slash and file-mention completion behind one canonical completion context, adding quoted `@"path with spaces"` file mentions.
|
|
36
|
+
- Index workspace file mentions asynchronously with a cwd-aware, generation-owned snapshot index so completion never blocks on disk or Git scans.
|
|
37
|
+
- Harden prompt completion: Escape now dismisses the slash-argument menu (not just draft commands), file-mention highlighting no longer swallows trailing prose punctuation, scoped `@dir/name` completions rank basename-prefix matches first, workspace Git scans drain stdout/stderr concurrently to avoid a pipe deadlock, and a mid-session working-directory removal no longer crashes the prompt turn.
|
|
38
|
+
- Keep the interactive prompt scene within the terminal height with a priority-ordered row allocator, and shut prompt background tasks/processes down with an awaited lifecycle.
|
|
39
|
+
- Add xAI Grok OAuth login (browser loopback and device-code).
|
|
40
|
+
- Add GitHub Copilot device-code OAuth login for individual github.com accounts.
|
|
41
|
+
- Add DigitalOcean Gradient AI browser OAuth login with dynamically discovered Inference Routers; router-discovery failures (unauthorized, outage, malformed, empty) are now reported distinctly instead of silently yielding no models.
|
|
42
|
+
- Add Snowflake Cortex account-scoped browser OAuth login (`pythinker login --snowflake`); the account identifier is validated before any request, and models register without becoming the default until Cortex chat support ships.
|
|
43
|
+
- Discover each provider's models from a cached, typed, provider-neutral models.dev catalog with curated fallbacks, so xAI, GitHub Copilot, and Snowflake pick up new models automatically; the catalog is now portable across platforms (no longer Unix-only) and distinguishes fresh, cached, stale, disabled, and unavailable results.
|
|
44
|
+
- Persist provider login and logout atomically so a failed save never leaves orphaned credentials or a half-applied configuration; a re-login whose save fails now restores the previous credential instead of deleting it, and provider persistence failures are logged.
|
|
45
|
+
- Fix queued follow-up input showing a bordered ghost; pressing Enter during an active turn now shows one intentional queued row.
|
|
46
|
+
- Harden provider OAuth credential handling: scope token refresh to the active/selected provider, serialize persistence under a lock with atomic config replacement, roll back replaced credentials when a save fails, fail closed on credential migration, and validate OAuth token and implicit-state responses.
|
|
47
|
+
- Fix an intermittent doubled/"ghost" copy of the running-prompt block (agent tree, spinner, and tip) on long streaming turns: after a scrollback handoff the suppressed live body now waits for the terminal's re-requested absolute cursor position to settle before it re-expands, so it repaints against a correct cursor model instead of the mis-anchored frame left behind by `run_in_terminal`.
|
|
48
|
+
- Fix the "update available" footer notice being suppressed on shell-mode frames (and a stale agent-mode notice replaying after a mode switch): the shell prompt render now refreshes the per-frame update-notice snapshot like the agent path, and the snapshot is cleared with the frame.
|
|
49
|
+
|
|
18
50
|
## 0.60.0 (2026-07-18)
|
|
19
51
|
|
|
20
52
|
- **Leaf subagent prompt profile.** All 12 built-in subagent roles (implementer,
|
pythinker_code/auth/__init__.py
CHANGED
|
@@ -9,10 +9,14 @@ MINIMAX_PLATFORM_ID = "minimax"
|
|
|
9
9
|
MOONSHOT_PLATFORM_ID = "moonshot"
|
|
10
10
|
KIMI_PLATFORM_ID = "kimi"
|
|
11
11
|
DEEPSEEK_PLATFORM_ID = "deepseek"
|
|
12
|
+
DIGITALOCEAN_PLATFORM_ID = "digitalocean"
|
|
13
|
+
GITHUB_COPILOT_PLATFORM_ID = "copilot"
|
|
12
14
|
ANTHROPIC_PLATFORM_ID = "anthropic"
|
|
13
15
|
OPENROUTER_PLATFORM_ID = "openrouter"
|
|
16
|
+
SNOWFLAKE_CORTEX_PLATFORM_ID = "snowflake-cortex"
|
|
14
17
|
LM_STUDIO_PLATFORM_ID = "lm-studio"
|
|
15
18
|
OLLAMA_PLATFORM_ID = "ollama"
|
|
19
|
+
XAI_PLATFORM_ID = "xai"
|
|
16
20
|
ZAI_CODING_PLATFORM_ID = "z-ai-coding"
|
|
17
21
|
ZAI_API_PLATFORM_ID = "z-ai-api"
|
|
18
22
|
|
|
@@ -20,6 +24,8 @@ __all__ = [
|
|
|
20
24
|
"ALIBABA_PLATFORM_ID",
|
|
21
25
|
"ANTHROPIC_PLATFORM_ID",
|
|
22
26
|
"DEEPSEEK_PLATFORM_ID",
|
|
27
|
+
"DIGITALOCEAN_PLATFORM_ID",
|
|
28
|
+
"GITHUB_COPILOT_PLATFORM_ID",
|
|
23
29
|
"KIMI_PLATFORM_ID",
|
|
24
30
|
"LM_STUDIO_PLATFORM_ID",
|
|
25
31
|
"MINIMAX_PLATFORM_ID",
|
|
@@ -30,6 +36,8 @@ __all__ = [
|
|
|
30
36
|
"OPENCODE_GO_PLATFORM_ID",
|
|
31
37
|
"OPENROUTER_PLATFORM_ID",
|
|
32
38
|
"PYTHINKER_CODE_PLATFORM_ID",
|
|
39
|
+
"SNOWFLAKE_CORTEX_PLATFORM_ID",
|
|
40
|
+
"XAI_PLATFORM_ID",
|
|
33
41
|
"ZAI_API_PLATFORM_ID",
|
|
34
42
|
"ZAI_CODING_PLATFORM_ID",
|
|
35
43
|
]
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
from pydantic import SecretStr
|
|
9
|
+
|
|
10
|
+
from pythinker_code.auth import GITHUB_COPILOT_PLATFORM_ID
|
|
11
|
+
from pythinker_code.auth.models_dev import (
|
|
12
|
+
CatalogModel,
|
|
13
|
+
build_catalog_models,
|
|
14
|
+
get_models_dev_catalog,
|
|
15
|
+
)
|
|
16
|
+
from pythinker_code.auth.oauth import (
|
|
17
|
+
OAuthError,
|
|
18
|
+
OAuthEvent,
|
|
19
|
+
OAuthToken,
|
|
20
|
+
OAuthUnauthorized,
|
|
21
|
+
persist_login,
|
|
22
|
+
persist_logout,
|
|
23
|
+
)
|
|
24
|
+
from pythinker_code.auth.oauth_flows import poll_device_token, request_device_code
|
|
25
|
+
from pythinker_code.auth.platforms import managed_model_key, managed_provider_key
|
|
26
|
+
from pythinker_code.config import Config, LLMModel, LLMProvider, OAuthRef
|
|
27
|
+
from pythinker_code.thinking import apply_login_thinking_defaults
|
|
28
|
+
from pythinker_code.utils.aiohttp import new_client_session
|
|
29
|
+
from pythinker_code.utils.logging import logger
|
|
30
|
+
|
|
31
|
+
GITHUB_COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
|
32
|
+
GITHUB_COPILOT_SCOPE = "read:user"
|
|
33
|
+
GITHUB_DEVICE_CODE_ENDPOINT = "https://github.com/login/device/code"
|
|
34
|
+
GITHUB_TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token"
|
|
35
|
+
GITHUB_COPILOT_TOKEN_ENDPOINT = "https://api.github.com/copilot_internal/v2/token"
|
|
36
|
+
GITHUB_COPILOT_OAUTH_KEY = "oauth/github-copilot"
|
|
37
|
+
GITHUB_COPILOT_PROVIDER_KEY = managed_provider_key(GITHUB_COPILOT_PLATFORM_ID)
|
|
38
|
+
COPILOT_BASE_URL = "https://api.githubcopilot.com"
|
|
39
|
+
GITHUB_COPILOT_MODELS_DEV_PROVIDER_ID = "github-copilot"
|
|
40
|
+
GITHUB_COPILOT_DEFAULT_CONTEXT = 128_000
|
|
41
|
+
|
|
42
|
+
GITHUB_JSON_HEADERS = {"Accept": "application/json"}
|
|
43
|
+
_EDITOR_VERSION = "vscode/1.99.0"
|
|
44
|
+
_EDITOR_PLUGIN_VERSION = "copilot-chat/0.26.7"
|
|
45
|
+
_USER_AGENT = "GitHubCopilotChat/0.26.7"
|
|
46
|
+
_GITHUB_API_VERSION = "2025-04-01"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_copilot_headers() -> dict[str, str]:
|
|
50
|
+
return {
|
|
51
|
+
"Copilot-Integration-Id": "vscode-chat",
|
|
52
|
+
"Editor-Version": _EDITOR_VERSION,
|
|
53
|
+
"Editor-Plugin-Version": _EDITOR_PLUGIN_VERSION,
|
|
54
|
+
"User-Agent": _USER_AGENT,
|
|
55
|
+
"X-GitHub-Api-Version": _GITHUB_API_VERSION,
|
|
56
|
+
"Openai-Intent": "conversation-panel",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_exchange_headers(github_token: str) -> dict[str, str]:
|
|
61
|
+
return {
|
|
62
|
+
"Authorization": f"token {github_token}",
|
|
63
|
+
"Editor-Version": _EDITOR_VERSION,
|
|
64
|
+
"Editor-Plugin-Version": _EDITOR_PLUGIN_VERSION,
|
|
65
|
+
"User-Agent": _USER_AGENT,
|
|
66
|
+
"X-GitHub-Api-Version": _GITHUB_API_VERSION,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class GitHubCopilotModel:
|
|
72
|
+
model_id: str
|
|
73
|
+
display_name: str
|
|
74
|
+
max_context_size: int
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def alias(self) -> str:
|
|
78
|
+
return managed_model_key(GITHUB_COPILOT_PLATFORM_ID, self.model_id)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
GITHUB_COPILOT_MODELS: tuple[GitHubCopilotModel, ...] = (
|
|
82
|
+
GitHubCopilotModel("gpt-4.1", "GPT-4.1", 1_000_000),
|
|
83
|
+
GitHubCopilotModel("gpt-4o", "GPT-4o", 128_000),
|
|
84
|
+
GitHubCopilotModel("o4-mini", "o4-mini", 200_000),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _copilot_oauth_ref() -> OAuthRef:
|
|
89
|
+
return OAuthRef(storage="file", key=GITHUB_COPILOT_OAUTH_KEY)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _apply_copilot_config(
|
|
93
|
+
config: Config, models: tuple[GitHubCopilotModel, ...] = GITHUB_COPILOT_MODELS
|
|
94
|
+
) -> None:
|
|
95
|
+
oauth_ref = _copilot_oauth_ref()
|
|
96
|
+
config.providers[GITHUB_COPILOT_PROVIDER_KEY] = LLMProvider(
|
|
97
|
+
type="openai_legacy",
|
|
98
|
+
base_url=COPILOT_BASE_URL,
|
|
99
|
+
api_key=SecretStr(""),
|
|
100
|
+
oauth=oauth_ref,
|
|
101
|
+
custom_headers=build_copilot_headers(),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
for alias, model in list(config.models.items()):
|
|
105
|
+
if model.provider == GITHUB_COPILOT_PROVIDER_KEY:
|
|
106
|
+
del config.models[alias]
|
|
107
|
+
|
|
108
|
+
for model in models:
|
|
109
|
+
config.models[model.alias] = LLMModel(
|
|
110
|
+
provider=GITHUB_COPILOT_PROVIDER_KEY,
|
|
111
|
+
model=model.model_id,
|
|
112
|
+
max_context_size=model.max_context_size,
|
|
113
|
+
display_name=model.display_name,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if models:
|
|
117
|
+
config.default_model = models[0].alias
|
|
118
|
+
apply_login_thinking_defaults(config, thinking=False, effort="off")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _catalog_models_to_copilot(built: tuple[CatalogModel, ...]) -> tuple[GitHubCopilotModel, ...]:
|
|
122
|
+
return tuple(
|
|
123
|
+
GitHubCopilotModel(model.model_id, model.display_name, model.max_context_size)
|
|
124
|
+
for model in built
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _discover_copilot_models() -> tuple[GitHubCopilotModel, ...]:
|
|
129
|
+
"""Resolve the live Copilot model list from the shared models.dev catalog.
|
|
130
|
+
|
|
131
|
+
Falls back to the curated list when the catalog is unavailable or degraded.
|
|
132
|
+
"""
|
|
133
|
+
result = await get_models_dev_catalog()
|
|
134
|
+
if not result.is_authoritative:
|
|
135
|
+
logger.debug(
|
|
136
|
+
"models.dev catalog not authoritative (status={status}, source={source}); "
|
|
137
|
+
"using curated GitHub Copilot models.",
|
|
138
|
+
status=result.status,
|
|
139
|
+
source=result.source,
|
|
140
|
+
)
|
|
141
|
+
return GITHUB_COPILOT_MODELS
|
|
142
|
+
built = build_catalog_models(
|
|
143
|
+
result.catalog,
|
|
144
|
+
GITHUB_COPILOT_MODELS_DEV_PROVIDER_ID,
|
|
145
|
+
default_context=GITHUB_COPILOT_DEFAULT_CONTEXT,
|
|
146
|
+
)
|
|
147
|
+
converted = _catalog_models_to_copilot(built)
|
|
148
|
+
if converted:
|
|
149
|
+
return converted
|
|
150
|
+
logger.debug(
|
|
151
|
+
"models.dev catalog contained no usable GitHub Copilot models "
|
|
152
|
+
"(status={status}, source={source}); using curated models.",
|
|
153
|
+
status=result.status,
|
|
154
|
+
source=result.source,
|
|
155
|
+
)
|
|
156
|
+
return GITHUB_COPILOT_MODELS
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def refresh_copilot_token(github_token: str) -> OAuthToken:
|
|
160
|
+
try:
|
|
161
|
+
async with (
|
|
162
|
+
new_client_session() as session,
|
|
163
|
+
session.get(
|
|
164
|
+
GITHUB_COPILOT_TOKEN_ENDPOINT,
|
|
165
|
+
headers=_build_exchange_headers(github_token),
|
|
166
|
+
) as response,
|
|
167
|
+
):
|
|
168
|
+
status = response.status
|
|
169
|
+
try:
|
|
170
|
+
payload_any: Any = await response.json(content_type=None)
|
|
171
|
+
except ValueError:
|
|
172
|
+
payload_any = None
|
|
173
|
+
except (aiohttp.ClientError, TimeoutError, OSError) as exc:
|
|
174
|
+
raise OAuthError("GitHub Copilot token exchange request failed.") from exc
|
|
175
|
+
|
|
176
|
+
if status in {401, 403}:
|
|
177
|
+
raise OAuthUnauthorized("GitHub Copilot token exchange was unauthorized.")
|
|
178
|
+
if status != 200:
|
|
179
|
+
raise OAuthError(f"GitHub Copilot token exchange failed (HTTP {status}).")
|
|
180
|
+
if not isinstance(payload_any, dict):
|
|
181
|
+
raise OAuthError("GitHub Copilot token exchange returned an invalid response.")
|
|
182
|
+
|
|
183
|
+
payload = cast(dict[str, Any], payload_any)
|
|
184
|
+
bearer = payload.get("token")
|
|
185
|
+
expires_at = payload.get("expires_at")
|
|
186
|
+
refresh_in = payload.get("refresh_in")
|
|
187
|
+
if (
|
|
188
|
+
not isinstance(bearer, str)
|
|
189
|
+
or not bearer
|
|
190
|
+
or isinstance(expires_at, bool)
|
|
191
|
+
or not isinstance(expires_at, int)
|
|
192
|
+
or expires_at <= 0
|
|
193
|
+
or isinstance(refresh_in, bool)
|
|
194
|
+
or not isinstance(refresh_in, int)
|
|
195
|
+
or refresh_in <= 0
|
|
196
|
+
):
|
|
197
|
+
raise OAuthError("GitHub Copilot token exchange returned an incomplete response.")
|
|
198
|
+
|
|
199
|
+
return OAuthToken(
|
|
200
|
+
access_token=bearer,
|
|
201
|
+
refresh_token=github_token,
|
|
202
|
+
expires_at=float(expires_at),
|
|
203
|
+
scope=GITHUB_COPILOT_SCOPE,
|
|
204
|
+
token_type="Bearer",
|
|
205
|
+
expires_in=float(refresh_in),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def login_copilot(config: Config, *, open_browser: bool = True) -> AsyncIterator[OAuthEvent]:
|
|
210
|
+
if not config.is_from_default_location:
|
|
211
|
+
yield OAuthEvent(
|
|
212
|
+
"error",
|
|
213
|
+
"Login requires the default config file; restart without --config/--config-file.",
|
|
214
|
+
)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
device_code = await request_device_code(
|
|
219
|
+
device_authorization_endpoint=GITHUB_DEVICE_CODE_ENDPOINT,
|
|
220
|
+
client_id=GITHUB_COPILOT_CLIENT_ID,
|
|
221
|
+
scope=GITHUB_COPILOT_SCOPE,
|
|
222
|
+
headers=GITHUB_JSON_HEADERS,
|
|
223
|
+
)
|
|
224
|
+
except OAuthError as exc:
|
|
225
|
+
yield OAuthEvent("error", f"Failed to start GitHub Copilot login: {exc}")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
yield OAuthEvent(
|
|
229
|
+
"verification_url",
|
|
230
|
+
f"Open {device_code.verification_uri} and enter code {device_code.user_code}.",
|
|
231
|
+
data={
|
|
232
|
+
"verification_url": device_code.verification_uri,
|
|
233
|
+
"user_code": device_code.user_code,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
if open_browser:
|
|
237
|
+
try:
|
|
238
|
+
from pythinker_code.utils.term import open_url_in_browser
|
|
239
|
+
|
|
240
|
+
open_url_in_browser(device_code.verification_uri)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
logger.warning("Failed to open browser: {error}", error=exc)
|
|
243
|
+
yield OAuthEvent("waiting", "Waiting for GitHub Copilot authorization...")
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
payload = await poll_device_token(
|
|
247
|
+
token_endpoint=GITHUB_TOKEN_ENDPOINT,
|
|
248
|
+
client_id=GITHUB_COPILOT_CLIENT_ID,
|
|
249
|
+
device_code=device_code,
|
|
250
|
+
headers=GITHUB_JSON_HEADERS,
|
|
251
|
+
)
|
|
252
|
+
github_token = payload.get("access_token")
|
|
253
|
+
if not isinstance(github_token, str) or not github_token:
|
|
254
|
+
raise OAuthError("GitHub device token response was incomplete.")
|
|
255
|
+
copilot_token = await refresh_copilot_token(github_token)
|
|
256
|
+
except OAuthError as exc:
|
|
257
|
+
yield OAuthEvent("error", f"GitHub Copilot login failed: {exc}")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
token = OAuthToken(
|
|
261
|
+
access_token=copilot_token.access_token,
|
|
262
|
+
refresh_token=github_token,
|
|
263
|
+
expires_at=copilot_token.expires_at,
|
|
264
|
+
scope=copilot_token.scope,
|
|
265
|
+
token_type=copilot_token.token_type,
|
|
266
|
+
expires_in=copilot_token.expires_in,
|
|
267
|
+
)
|
|
268
|
+
models = await _discover_copilot_models()
|
|
269
|
+
try:
|
|
270
|
+
await persist_login(
|
|
271
|
+
config, _copilot_oauth_ref(), token, lambda cfg: _apply_copilot_config(cfg, models)
|
|
272
|
+
)
|
|
273
|
+
except Exception as exc:
|
|
274
|
+
logger.warning("Failed to persist GitHub Copilot login: {exc}", exc=exc)
|
|
275
|
+
yield OAuthEvent("error", "Failed to save GitHub Copilot login.")
|
|
276
|
+
return
|
|
277
|
+
yield OAuthEvent(
|
|
278
|
+
"success",
|
|
279
|
+
f"GitHub Copilot configured with model {config.default_model}.",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def logout_copilot(config: Config) -> AsyncIterator[OAuthEvent]:
|
|
284
|
+
if not config.is_from_default_location:
|
|
285
|
+
yield OAuthEvent(
|
|
286
|
+
"error",
|
|
287
|
+
"Logout requires the default config file; restart without --config/--config-file.",
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
def _remove(cfg: Config) -> None:
|
|
292
|
+
cfg.providers.pop(GITHUB_COPILOT_PROVIDER_KEY, None)
|
|
293
|
+
for alias, model in list(cfg.models.items()):
|
|
294
|
+
if model.provider == GITHUB_COPILOT_PROVIDER_KEY:
|
|
295
|
+
del cfg.models[alias]
|
|
296
|
+
if cfg.default_model not in cfg.models:
|
|
297
|
+
cfg.default_model = next(iter(cfg.models), "")
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
await persist_logout(config, _copilot_oauth_ref(), _remove)
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
logger.warning("Failed to persist GitHub Copilot logout: {exc}", exc=exc)
|
|
303
|
+
yield OAuthEvent("error", "Failed to log out of GitHub Copilot.")
|
|
304
|
+
return
|
|
305
|
+
yield OAuthEvent("success", "Logged out of GitHub Copilot successfully.")
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from pydantic import SecretStr
|
|
10
|
+
|
|
11
|
+
from pythinker_code.auth import DIGITALOCEAN_PLATFORM_ID
|
|
12
|
+
from pythinker_code.auth.oauth import OAuthError, OAuthEvent, persist_config_change
|
|
13
|
+
from pythinker_code.auth.oauth_flows import run_loopback_implicit_flow
|
|
14
|
+
from pythinker_code.auth.platforms import managed_model_key, managed_provider_key
|
|
15
|
+
from pythinker_code.config import Config, LLMModel, LLMProvider
|
|
16
|
+
from pythinker_code.thinking import apply_login_thinking_defaults
|
|
17
|
+
from pythinker_code.utils.aiohttp import new_client_session
|
|
18
|
+
from pythinker_code.utils.logging import logger
|
|
19
|
+
|
|
20
|
+
DIGITALOCEAN_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
|
|
21
|
+
DIGITALOCEAN_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
|
|
22
|
+
DIGITALOCEAN_SCOPE = "genai:read inference:query"
|
|
23
|
+
DIGITALOCEAN_BASE_URL = "https://inference.do-ai.run/v1"
|
|
24
|
+
DIGITALOCEAN_ROUTERS_URL = "https://api.digitalocean.com/v2/gen-ai/models/routers"
|
|
25
|
+
DIGITALOCEAN_REDIRECT_PORT = 1456
|
|
26
|
+
DIGITALOCEAN_CALLBACK_PATH = "/auth/callback"
|
|
27
|
+
DIGITALOCEAN_TOKEN_PATH = "/auth/token"
|
|
28
|
+
DIGITALOCEAN_PROVIDER_KEY = managed_provider_key(DIGITALOCEAN_PLATFORM_ID)
|
|
29
|
+
DIGITALOCEAN_DEFAULT_CONTEXT = 128_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RouterDiscovery(str, Enum):
|
|
33
|
+
"""Outcome of a DigitalOcean inference-router discovery call."""
|
|
34
|
+
|
|
35
|
+
OK = "ok" # routers were discovered
|
|
36
|
+
PARTIAL = "partial" # valid routers were discovered alongside malformed entries
|
|
37
|
+
EMPTY = "empty" # the account has no routers (valid, but empty)
|
|
38
|
+
UNAUTHORIZED = "unauthorized" # the token could not list routers
|
|
39
|
+
UNAVAILABLE = "unavailable" # timeout / outage / non-2xx response
|
|
40
|
+
MALFORMED = "malformed" # the response body was not the expected shape
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class RouterCatalog:
|
|
45
|
+
"""Discovered routers paired with the outcome that produced them."""
|
|
46
|
+
|
|
47
|
+
status: RouterDiscovery
|
|
48
|
+
names: tuple[str, ...]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _skip_browser_open(_url: str) -> None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _apply_digitalocean_config(
|
|
56
|
+
config: Config,
|
|
57
|
+
api_key: SecretStr,
|
|
58
|
+
router_names: tuple[str, ...],
|
|
59
|
+
) -> None:
|
|
60
|
+
config.providers[DIGITALOCEAN_PROVIDER_KEY] = LLMProvider(
|
|
61
|
+
type="openai_legacy",
|
|
62
|
+
base_url=DIGITALOCEAN_BASE_URL,
|
|
63
|
+
api_key=api_key,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
for alias, model in list(config.models.items()):
|
|
67
|
+
if model.provider == DIGITALOCEAN_PROVIDER_KEY:
|
|
68
|
+
del config.models[alias]
|
|
69
|
+
|
|
70
|
+
for name in router_names:
|
|
71
|
+
model_id = f"router:{name}"
|
|
72
|
+
alias = managed_model_key(DIGITALOCEAN_PLATFORM_ID, model_id)
|
|
73
|
+
config.models[alias] = LLMModel(
|
|
74
|
+
provider=DIGITALOCEAN_PROVIDER_KEY,
|
|
75
|
+
model=model_id,
|
|
76
|
+
max_context_size=DIGITALOCEAN_DEFAULT_CONTEXT,
|
|
77
|
+
display_name=name,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if router_names:
|
|
81
|
+
config.default_model = managed_model_key(
|
|
82
|
+
DIGITALOCEAN_PLATFORM_ID,
|
|
83
|
+
f"router:{router_names[0]}",
|
|
84
|
+
)
|
|
85
|
+
elif config.default_model not in config.models:
|
|
86
|
+
config.default_model = next(iter(config.models), "")
|
|
87
|
+
apply_login_thinking_defaults(config, thinking=False, effort="off")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_router_names(payload: object) -> RouterCatalog:
|
|
91
|
+
if not isinstance(payload, dict):
|
|
92
|
+
return RouterCatalog(RouterDiscovery.MALFORMED, ())
|
|
93
|
+
raw = cast(dict[str, Any], payload).get("model_routers")
|
|
94
|
+
if not isinstance(raw, list):
|
|
95
|
+
return RouterCatalog(RouterDiscovery.MALFORMED, ())
|
|
96
|
+
if not raw:
|
|
97
|
+
return RouterCatalog(RouterDiscovery.EMPTY, ())
|
|
98
|
+
|
|
99
|
+
names: list[str] = []
|
|
100
|
+
has_malformed_entry = False
|
|
101
|
+
for item in cast(list[Any], raw):
|
|
102
|
+
if isinstance(item, dict):
|
|
103
|
+
name = cast(dict[str, Any], item).get("name")
|
|
104
|
+
if isinstance(name, str) and name.strip():
|
|
105
|
+
names.append(name)
|
|
106
|
+
continue
|
|
107
|
+
has_malformed_entry = True
|
|
108
|
+
if not names:
|
|
109
|
+
return RouterCatalog(RouterDiscovery.MALFORMED, ())
|
|
110
|
+
if has_malformed_entry:
|
|
111
|
+
return RouterCatalog(RouterDiscovery.PARTIAL, tuple(names))
|
|
112
|
+
return RouterCatalog(RouterDiscovery.OK, tuple(names))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _fetch_router_catalog(access_token: str) -> RouterCatalog:
|
|
116
|
+
"""Discover the account's inference routers, preserving distinct outcomes.
|
|
117
|
+
|
|
118
|
+
Authentication failures, dependency outages, malformed responses, and a
|
|
119
|
+
valid-but-empty catalog are each reported separately so the login flow can
|
|
120
|
+
surface an accurate message instead of collapsing everything into "no
|
|
121
|
+
routers".
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
async with (
|
|
125
|
+
new_client_session() as session,
|
|
126
|
+
session.get(
|
|
127
|
+
DIGITALOCEAN_ROUTERS_URL,
|
|
128
|
+
headers={
|
|
129
|
+
"Authorization": f"Bearer {access_token}",
|
|
130
|
+
"Accept": "application/json",
|
|
131
|
+
},
|
|
132
|
+
) as response,
|
|
133
|
+
):
|
|
134
|
+
status = response.status
|
|
135
|
+
if status in (401, 403):
|
|
136
|
+
return RouterCatalog(RouterDiscovery.UNAUTHORIZED, ())
|
|
137
|
+
if not 200 <= status < 300:
|
|
138
|
+
return RouterCatalog(RouterDiscovery.UNAVAILABLE, ())
|
|
139
|
+
try:
|
|
140
|
+
payload: Any = await response.json(content_type=None)
|
|
141
|
+
except (ValueError, aiohttp.ClientError):
|
|
142
|
+
return RouterCatalog(RouterDiscovery.MALFORMED, ())
|
|
143
|
+
except (TimeoutError, aiohttp.ClientError, OSError):
|
|
144
|
+
return RouterCatalog(RouterDiscovery.UNAVAILABLE, ())
|
|
145
|
+
|
|
146
|
+
return _parse_router_names(payload)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _router_status_message(status: RouterDiscovery) -> str | None:
|
|
150
|
+
if status is RouterDiscovery.OK:
|
|
151
|
+
return None
|
|
152
|
+
if status is RouterDiscovery.PARTIAL:
|
|
153
|
+
return (
|
|
154
|
+
"DigitalOcean returned some malformed inference-router entries; sign-in saved "
|
|
155
|
+
"with valid routers configured and malformed entries ignored."
|
|
156
|
+
)
|
|
157
|
+
if status is RouterDiscovery.EMPTY:
|
|
158
|
+
return "DigitalOcean returned no inference routers; sign-in saved with no models."
|
|
159
|
+
if status is RouterDiscovery.MALFORMED:
|
|
160
|
+
return (
|
|
161
|
+
"DigitalOcean returned entirely malformed inference-router data; sign-in saved "
|
|
162
|
+
"with no models configured."
|
|
163
|
+
)
|
|
164
|
+
if status is RouterDiscovery.UNAUTHORIZED:
|
|
165
|
+
return (
|
|
166
|
+
"DigitalOcean did not authorize inference-router discovery; sign-in saved. "
|
|
167
|
+
"Re-run login once the account has inference access."
|
|
168
|
+
)
|
|
169
|
+
return (
|
|
170
|
+
"DigitalOcean Inference Routers were unavailable; sign-in saved. "
|
|
171
|
+
"Re-run login to load routers."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def login_digitalocean(
|
|
176
|
+
config: Config, *, open_browser: bool = True
|
|
177
|
+
) -> AsyncIterator[OAuthEvent]:
|
|
178
|
+
if not config.is_from_default_location:
|
|
179
|
+
yield OAuthEvent(
|
|
180
|
+
"error",
|
|
181
|
+
"Login requires the default config file; restart without --config/--config-file.",
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
yield OAuthEvent("waiting", "Waiting for DigitalOcean browser authorization...")
|
|
186
|
+
try:
|
|
187
|
+
auth = await run_loopback_implicit_flow(
|
|
188
|
+
authorize_endpoint=DIGITALOCEAN_AUTHORIZE_URL,
|
|
189
|
+
client_id=DIGITALOCEAN_CLIENT_ID,
|
|
190
|
+
scope=DIGITALOCEAN_SCOPE,
|
|
191
|
+
callback_path=DIGITALOCEAN_CALLBACK_PATH,
|
|
192
|
+
token_path=DIGITALOCEAN_TOKEN_PATH,
|
|
193
|
+
port=DIGITALOCEAN_REDIRECT_PORT,
|
|
194
|
+
browser_open=None if open_browser else _skip_browser_open,
|
|
195
|
+
)
|
|
196
|
+
except OAuthError as exc:
|
|
197
|
+
yield OAuthEvent("error", f"DigitalOcean browser login failed: {exc}")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
catalog = await _fetch_router_catalog(auth.access_token)
|
|
201
|
+
try:
|
|
202
|
+
await persist_config_change(
|
|
203
|
+
config,
|
|
204
|
+
lambda cfg: _apply_digitalocean_config(
|
|
205
|
+
cfg, SecretStr(auth.access_token), catalog.names
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
logger.warning("Failed to persist DigitalOcean login: {exc}", exc=exc)
|
|
210
|
+
yield OAuthEvent("error", "Failed to save DigitalOcean login.")
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
message = _router_status_message(catalog.status)
|
|
214
|
+
if message:
|
|
215
|
+
yield OAuthEvent("info", message)
|
|
216
|
+
if catalog.names:
|
|
217
|
+
success_message = f"DigitalOcean configured with model {config.default_model}."
|
|
218
|
+
else:
|
|
219
|
+
success_message = "DigitalOcean credentials saved; no inference routers are configured."
|
|
220
|
+
yield OAuthEvent("success", success_message)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def logout_digitalocean(config: Config) -> AsyncIterator[OAuthEvent]:
|
|
224
|
+
if not config.is_from_default_location:
|
|
225
|
+
yield OAuthEvent(
|
|
226
|
+
"error",
|
|
227
|
+
"Logout requires the default config file; restart without --config/--config-file.",
|
|
228
|
+
)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
def _remove(cfg: Config) -> None:
|
|
232
|
+
cfg.providers.pop(DIGITALOCEAN_PROVIDER_KEY, None)
|
|
233
|
+
for alias, model in list(cfg.models.items()):
|
|
234
|
+
if model.provider == DIGITALOCEAN_PROVIDER_KEY:
|
|
235
|
+
del cfg.models[alias]
|
|
236
|
+
if cfg.default_model not in cfg.models:
|
|
237
|
+
cfg.default_model = next(iter(cfg.models), "")
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
await persist_config_change(config, _remove)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
logger.warning("Failed to persist DigitalOcean logout: {exc}", exc=exc)
|
|
243
|
+
yield OAuthEvent("error", "Failed to log out of DigitalOcean.")
|
|
244
|
+
return
|
|
245
|
+
yield OAuthEvent("success", "Logged out of DigitalOcean successfully.")
|