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.
Files changed (126) hide show
  1. pythinker_code/CHANGELOG.md +32 -0
  2. pythinker_code/auth/__init__.py +8 -0
  3. pythinker_code/auth/copilot.py +305 -0
  4. pythinker_code/auth/digitalocean.py +245 -0
  5. pythinker_code/auth/models_dev.py +454 -0
  6. pythinker_code/auth/oauth.py +697 -75
  7. pythinker_code/auth/oauth_flows.py +753 -0
  8. pythinker_code/auth/opencode_go.py +31 -53
  9. pythinker_code/auth/platforms.py +142 -46
  10. pythinker_code/auth/snowflake.py +371 -0
  11. pythinker_code/auth/xai.py +340 -0
  12. pythinker_code/cli/__init__.py +122 -7
  13. pythinker_code/config.py +30 -11
  14. pythinker_code/subagents/runner.py +3 -0
  15. pythinker_code/tools/lsp/tool.py +4 -4
  16. pythinker_code/ui/shell/__init__.py +133 -92
  17. pythinker_code/ui/shell/command_runner.py +135 -0
  18. pythinker_code/ui/shell/oauth.py +78 -7
  19. pythinker_code/ui/shell/prompt.py +1046 -2411
  20. pythinker_code/ui/shell/prompting/__init__.py +67 -0
  21. pythinker_code/ui/shell/prompting/clipboard.py +113 -0
  22. pythinker_code/ui/shell/prompting/completion/__init__.py +43 -0
  23. pythinker_code/ui/shell/prompting/completion/context.py +278 -0
  24. pythinker_code/ui/shell/prompting/completion/menus.py +687 -0
  25. pythinker_code/ui/shell/prompting/completion/slash.py +350 -0
  26. pythinker_code/ui/shell/prompting/completion/workspace.py +570 -0
  27. pythinker_code/ui/shell/prompting/config.py +55 -0
  28. pythinker_code/ui/shell/prompting/footer.py +126 -0
  29. pythinker_code/ui/shell/prompting/frame.py +147 -0
  30. pythinker_code/ui/shell/prompting/git_status.py +407 -0
  31. pythinker_code/ui/shell/prompting/history.py +276 -0
  32. pythinker_code/ui/shell/prompting/keybindings.py +420 -0
  33. pythinker_code/ui/shell/prompting/lifecycle.py +77 -0
  34. pythinker_code/ui/shell/prompting/renderer.py +88 -0
  35. pythinker_code/ui/shell/prompting/state.py +322 -0
  36. pythinker_code/ui/shell/prompting/toasts.py +182 -0
  37. pythinker_code/ui/shell/replay.py +2 -1
  38. pythinker_code/ui/shell/slash.py +46 -0
  39. pythinker_code/ui/shell/statusline.py +18 -6
  40. pythinker_code/ui/shell/tool_renderers/agent.py +144 -122
  41. pythinker_code/ui/shell/update.py +9 -0
  42. pythinker_code/ui/shell/visualize/_blocks.py +384 -29
  43. pythinker_code/ui/shell/visualize/_interactive.py +73 -19
  44. pythinker_code/ui/shell/visualize/_live_view.py +208 -41
  45. pythinker_code/ui/theme/registry.py +7 -3
  46. pythinker_code/utils/io.py +60 -9
  47. pythinker_code/web/api/sessions.py +2 -1
  48. pythinker_code/web/static/assets/architecture-7EHR7CIX-7yUuXz_6.js +1 -0
  49. pythinker_code/web/static/assets/{architectureDiagram-3BPJPVTR-CQB35YNL.js → architectureDiagram-3BPJPVTR-kN5y1Jpf.js} +1 -1
  50. pythinker_code/web/static/assets/{blockDiagram-GPEHLZMM-Dzn-MvOQ.js → blockDiagram-GPEHLZMM-ay8f3ouR.js} +1 -1
  51. pythinker_code/web/static/assets/{bootstrap-Cg8OxEIk.js → bootstrap-S1KM-DEW.js} +5 -5
  52. pythinker_code/web/static/assets/{c4Diagram-AAUBKEIU-DOdn3M8j.js → c4Diagram-AAUBKEIU-CLTHO8TB.js} +1 -1
  53. pythinker_code/web/static/assets/channel-B8FBABBt.js +1 -0
  54. pythinker_code/web/static/assets/{chunk-2J33WTMH-D8y8jM6G.js → chunk-2J33WTMH-BVQ8lE9G.js} +1 -1
  55. pythinker_code/web/static/assets/{chunk-3OPIFGDE-CISKfk8B.js → chunk-3OPIFGDE-CWiguBMG.js} +1 -1
  56. pythinker_code/web/static/assets/{chunk-5ZQYHXKU-Cmu2q8Rh.js → chunk-5ZQYHXKU-B9Zlc-aT.js} +1 -1
  57. pythinker_code/web/static/assets/{chunk-727SXJPM-y8Ue8Q-r.js → chunk-727SXJPM-MImcCexG.js} +1 -1
  58. pythinker_code/web/static/assets/{chunk-AQP2D5EJ-BcacTxko.js → chunk-AQP2D5EJ-BOePrMEY.js} +1 -1
  59. pythinker_code/web/static/assets/{chunk-CSCIHK7Q-DmRHMJb7.js → chunk-CSCIHK7Q-BgUUe_OK.js} +1 -1
  60. pythinker_code/web/static/assets/{chunk-JAPRZBRM-CUxWX_xo.js → chunk-JAPRZBRM-CZdsfD3G.js} +4 -4
  61. pythinker_code/web/static/assets/{chunk-KSCS5N6A-DhJnqsdL.js → chunk-KSCS5N6A-Bt7bPLOL.js} +1 -1
  62. pythinker_code/web/static/assets/{chunk-L5ZTLDWV-NFkBi_Vm.js → chunk-L5ZTLDWV-BKkLltw7.js} +1 -1
  63. pythinker_code/web/static/assets/{chunk-LZXEDZCA-2s03CWIo.js → chunk-LZXEDZCA-DL8eQYxM.js} +2 -2
  64. pythinker_code/web/static/assets/{chunk-ND2GUHAM-M6TLIhoY.js → chunk-ND2GUHAM-BRB3B1Sj.js} +1 -1
  65. pythinker_code/web/static/assets/{chunk-NZK2D7GU-B636uOYb.js → chunk-NZK2D7GU-CEo2-R5k.js} +1 -1
  66. pythinker_code/web/static/assets/{chunk-O5CBEL6O-Dz7EQRTV.js → chunk-O5CBEL6O-CsFvKDJU.js} +1 -1
  67. pythinker_code/web/static/assets/{chunk-WU5MYG2G-BeEJ80kO.js → chunk-WU5MYG2G-a2xnNUhR.js} +1 -1
  68. pythinker_code/web/static/assets/classDiagram-4FO5ZUOK-D-cz27-m.js +1 -0
  69. pythinker_code/web/static/assets/classDiagram-v2-Q7XG4LA2-D-cz27-m.js +1 -0
  70. pythinker_code/web/static/assets/{code-block-IT6T5CEO-BC_pN-Yw.js → code-block-IT6T5CEO-2XeVzOg8.js} +1 -1
  71. pythinker_code/web/static/assets/{dagre-BM42HDAG-DvDDgf8V.js → dagre-BM42HDAG-Bpibp3hN.js} +1 -1
  72. pythinker_code/web/static/assets/{diagram-2AECGRRQ-mI4kgcAO.js → diagram-2AECGRRQ-Qk5WcKlr.js} +1 -1
  73. pythinker_code/web/static/assets/{diagram-5GNKFQAL-CPqhJA-b.js → diagram-5GNKFQAL-CZCQSYzJ.js} +1 -1
  74. pythinker_code/web/static/assets/{diagram-KO2AKTUF-okeubuOf.js → diagram-KO2AKTUF-C8AEI5hM.js} +1 -1
  75. pythinker_code/web/static/assets/{diagram-LMA3HP47-GpyhYIFC.js → diagram-LMA3HP47-BaLiJz83.js} +1 -1
  76. pythinker_code/web/static/assets/{diagram-OG6HWLK6-mspbAyR1.js → diagram-OG6HWLK6--4Nbejfz.js} +1 -1
  77. pythinker_code/web/static/assets/{dist-BRJOHEfJ.js → dist-ClYYVNiW.js} +1 -1
  78. pythinker_code/web/static/assets/{erDiagram-TEJ5UH35-D3-qDHzY.js → erDiagram-TEJ5UH35-B3CW9ZRe.js} +1 -1
  79. pythinker_code/web/static/assets/eventmodeling-FCH6USID-DLGeZepM.js +1 -0
  80. pythinker_code/web/static/assets/{flowDiagram-I6XJVG4X-CLGi2lTa.js → flowDiagram-I6XJVG4X-L2db82wq.js} +1 -1
  81. pythinker_code/web/static/assets/{ganttDiagram-6RSMTGT7-B3-QWCmD.js → ganttDiagram-6RSMTGT7-GW3eNThU.js} +1 -1
  82. pythinker_code/web/static/assets/{gitGraph-WXDBUCRP-VdtYpDJ_.js → gitGraph-WXDBUCRP-BHqLWd3S.js} +1 -1
  83. pythinker_code/web/static/assets/{gitGraphDiagram-PVQCEYII-CqoCv1c4.js → gitGraphDiagram-PVQCEYII-E0CFOqaX.js} +1 -1
  84. pythinker_code/web/static/assets/{index-Bwb07Fmw.js → index-1B9OiEzj.js} +2 -2
  85. pythinker_code/web/static/assets/{info-J43DQDTF-DJ8lwJdB.js → info-J43DQDTF-vcEEQm0V.js} +1 -1
  86. pythinker_code/web/static/assets/{infoDiagram-5YYISTIA-dsAKLNGZ.js → infoDiagram-5YYISTIA-BpmiyoRM.js} +1 -1
  87. pythinker_code/web/static/assets/{ishikawaDiagram-YF4QCWOH-Qhh01wyO.js → ishikawaDiagram-YF4QCWOH-D23o96ks.js} +1 -1
  88. pythinker_code/web/static/assets/{journeyDiagram-JHISSGLW-2b0X6J4l.js → journeyDiagram-JHISSGLW-DzEX_rL2.js} +1 -1
  89. pythinker_code/web/static/assets/{kanban-definition-UN3LZRKU-P70tWcUV.js → kanban-definition-UN3LZRKU-BfaBNxdU.js} +1 -1
  90. pythinker_code/web/static/assets/{line-LvudDuKP.js → line-D1x4nBuC.js} +1 -1
  91. pythinker_code/web/static/assets/mermaid-VLURNSYL-DnHrV-Cw.js +1 -0
  92. pythinker_code/web/static/assets/{mermaid-parser.core-BXce5aFh.js → mermaid-parser.core-Dg4dG-gq.js} +2 -2
  93. pythinker_code/web/static/assets/{mermaid.core-DAmC1ASV.js → mermaid.core-BxNHwSq2.js} +3 -3
  94. pythinker_code/web/static/assets/{mindmap-definition-RKZ34NQL-Cdyk5D3F.js → mindmap-definition-RKZ34NQL-D0Usem3o.js} +1 -1
  95. pythinker_code/web/static/assets/{packet-YPE3B663-BfGyictS.js → packet-YPE3B663-QyGpgkjT.js} +1 -1
  96. pythinker_code/web/static/assets/{pie-LRSECV5Y-B5QwRxIa.js → pie-LRSECV5Y-DaaSWw8w.js} +1 -1
  97. pythinker_code/web/static/assets/{pieDiagram-4H26LBE5-DjZjVQAH.js → pieDiagram-4H26LBE5-Bn6b-msm.js} +1 -1
  98. pythinker_code/web/static/assets/{quadrantDiagram-W4KKPZXB-BFdmBdAa.js → quadrantDiagram-W4KKPZXB-DylkTLVn.js} +1 -1
  99. pythinker_code/web/static/assets/{radar-GUYGQ44K-Del6SV-D.js → radar-GUYGQ44K-B5LtHmKt.js} +1 -1
  100. pythinker_code/web/static/assets/{requirementDiagram-4Y6WPE33-CbKF62IW.js → requirementDiagram-4Y6WPE33-B7P2ssJV.js} +1 -1
  101. pythinker_code/web/static/assets/{sankeyDiagram-5OEKKPKP-cKWw70Mq.js → sankeyDiagram-5OEKKPKP-CNTn-n2b.js} +1 -1
  102. pythinker_code/web/static/assets/{sequenceDiagram-3UESZ5HK-DowvJHGL.js → sequenceDiagram-3UESZ5HK-CF5Xisyl.js} +1 -1
  103. pythinker_code/web/static/assets/{stateDiagram-AJRCARHV-DENCh7DG.js → stateDiagram-AJRCARHV-d8g3ltDG.js} +1 -1
  104. pythinker_code/web/static/assets/stateDiagram-v2-BHNVJYJU-D9zus-0d.js +1 -0
  105. pythinker_code/web/static/assets/{timeline-definition-PNZ67QCA-D7vrwE4p.js → timeline-definition-PNZ67QCA-Jv4dMAzr.js} +1 -1
  106. pythinker_code/web/static/assets/{treeView-BLDUP644-BkpWpdte.js → treeView-BLDUP644-C3BmP5d3.js} +1 -1
  107. pythinker_code/web/static/assets/{treemap-LRROVOQU-O0XS2jca.js → treemap-LRROVOQU-r12SIbVM.js} +1 -1
  108. pythinker_code/web/static/assets/{vennDiagram-CIIHVFJN-BtzihevZ.js → vennDiagram-CIIHVFJN-0fySknsk.js} +1 -1
  109. pythinker_code/web/static/assets/{wardley-L42UT6IY-CNG86R2O.js → wardley-L42UT6IY-WzZrzUpE.js} +1 -1
  110. pythinker_code/web/static/assets/{wardleyDiagram-YWT4CUSO-CQswyIqM.js → wardleyDiagram-YWT4CUSO-BE4m4b4-.js} +1 -1
  111. pythinker_code/web/static/assets/{xychartDiagram-2RQKCTM6-DVYTvk-7.js → xychartDiagram-2RQKCTM6-WKhXzzGu.js} +1 -1
  112. pythinker_code/web/static/index.html +1 -1
  113. pythinker_code/wire/types.py +2 -0
  114. {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/METADATA +22 -23
  115. {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/RECORD +119 -95
  116. pythinker_code/web/static/assets/architecture-7EHR7CIX-Sj7uuR-x.js +0 -1
  117. pythinker_code/web/static/assets/channel-5kwaSm7B.js +0 -1
  118. pythinker_code/web/static/assets/classDiagram-4FO5ZUOK-CWIwj2q3.js +0 -1
  119. pythinker_code/web/static/assets/classDiagram-v2-Q7XG4LA2-CWIwj2q3.js +0 -1
  120. pythinker_code/web/static/assets/eventmodeling-FCH6USID-VIVurDG_.js +0 -1
  121. pythinker_code/web/static/assets/mermaid-VLURNSYL-CQhaV3xy.js +0 -1
  122. pythinker_code/web/static/assets/stateDiagram-v2-BHNVJYJU-DqQMlVC4.js +0 -1
  123. {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/WHEEL +0 -0
  124. {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/entry_points.txt +0 -0
  125. {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/licenses/LICENSE +0 -0
  126. {pythinker_code-0.60.0.dist-info → pythinker_code-0.62.0.dist-info}/licenses/NOTICE +0 -0
@@ -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,
@@ -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.")