meltygui 0.1.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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Anthropic sign-in — the interactive OAuth (PKCE) login that the official
|
|
2
|
+
`ant auth login` performs, run in-process so the Internet Accounts window
|
|
3
|
+
signs an Anthropic (Google / email) account in with one click and no key
|
|
4
|
+
to copy out of the Console.
|
|
5
|
+
|
|
6
|
+
What it produces is an SDK PROFILE, not a key: `configs/<profile>.json` +
|
|
7
|
+
`credentials/<profile>.json` under `$ANTHROPIC_CONFIG_DIR`
|
|
8
|
+
(`~/.config/anthropic`) — the exact files `anthropic.Anthropic(profile=…)`
|
|
9
|
+
reads and REFRESHES itself (refresh_token grant on expiry, rotated tokens
|
|
10
|
+
written back, `anthropic-workspace-id` header from the config), so after
|
|
11
|
+
a sign-in nothing here runs again until Sign out. The login runs as the
|
|
12
|
+
CLI's public OAuth client: a profile is bound to the client that minted
|
|
13
|
+
it (the SDK's refresh sends that client_id), and this way the profile is
|
|
14
|
+
one `ant` / the SDKs share. The studio always names its profile
|
|
15
|
+
explicitly (`Toggles.InternetAccounts.anthropic_profile`, never "default"
|
|
16
|
+
and never the `active_config` pointer), so Claude Code / a bare
|
|
17
|
+
`Anthropic()` elsewhere keep whatever login they have.
|
|
18
|
+
|
|
19
|
+
Flow (`LoginFlow`): bind 127.0.0.1:<ephemeral> → open the Console's
|
|
20
|
+
/oauth/authorize consent page in the SYSTEM browser (Google blocks its
|
|
21
|
+
sign-in inside embedded web views, so a real browser is the path that
|
|
22
|
+
works) → the Console redirects to http://localhost:<port>/callback with
|
|
23
|
+
`code` + `state` → exchange the code at /v1/oauth/token (form-encoded,
|
|
24
|
+
no beta header — the authorization_code grant) → write the profile.
|
|
25
|
+
Everything after `start()` runs on a worker thread; the window reads
|
|
26
|
+
`url` / `done` / `error` and calls `cancel()`.
|
|
27
|
+
|
|
28
|
+
Browser launch goes through copilot.open_url (full-path xdg-open via
|
|
29
|
+
posix_spawn — a fork of the CUDA/GL address space stalls the render
|
|
30
|
+
thread, project_subprocess_fork_stall).
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import base64
|
|
35
|
+
import hashlib
|
|
36
|
+
import html
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import secrets
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
import urllib.error
|
|
43
|
+
import urllib.parse
|
|
44
|
+
import urllib.request
|
|
45
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
DEFAULT_BASE_URL = "https://api.anthropic.com"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
52
|
+
# Profile files (the SDK's on-disk format, `anthropic/lib/credentials`)
|
|
53
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def config_dir() -> Path:
|
|
56
|
+
"""`$ANTHROPIC_CONFIG_DIR` or `~/.config/anthropic` — the SDK's rule."""
|
|
57
|
+
env = os.environ.get("ANTHROPIC_CONFIG_DIR")
|
|
58
|
+
return Path(env) if env else Path.home() / ".config" / "anthropic"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def profile_paths(profile: str) -> tuple[Path, Path]:
|
|
62
|
+
"""(configs/<profile>.json, credentials/<profile>.json)."""
|
|
63
|
+
_check_profile_name(profile)
|
|
64
|
+
base = config_dir()
|
|
65
|
+
return base / "configs" / f"{profile}.json", base / "credentials" / f"{profile}.json"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_profile_name(profile: str):
|
|
69
|
+
if (not profile or profile in (".", "..") or "/" in profile or "\\" in profile
|
|
70
|
+
or profile.startswith(".")):
|
|
71
|
+
raise ValueError(f"invalid profile name {profile!r}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def active_profile_present() -> bool:
|
|
75
|
+
"""True when a bare `anthropic.Anthropic()` would find a login on its own:
|
|
76
|
+
an `active_config` pointer or a `configs/default.json` (what `ant auth
|
|
77
|
+
login` / Claude Code write). The studio's named profile is NOT this —
|
|
78
|
+
it is passed as `profile=` explicitly."""
|
|
79
|
+
base = config_dir()
|
|
80
|
+
return (base / "active_config").is_file() or (base / "configs" / "default.json").is_file()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_profile(profile: str):
|
|
84
|
+
"""Who this profile is signed in as, from the credentials file (no
|
|
85
|
+
network): {"profile", "email", "organization", "workspace",
|
|
86
|
+
"expires_at", "refreshable", "path"} — or None when not signed in."""
|
|
87
|
+
try:
|
|
88
|
+
_, credentials_path = profile_paths(profile)
|
|
89
|
+
credentials = json.loads(credentials_path.read_text(encoding="utf-8"))
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
return None
|
|
92
|
+
if not isinstance(credentials, dict) or not credentials.get("access_token"):
|
|
93
|
+
return None
|
|
94
|
+
return {"profile": profile,
|
|
95
|
+
"email": credentials.get("account_email") or "",
|
|
96
|
+
"organization": credentials.get("organization_name") or "",
|
|
97
|
+
"workspace": credentials.get("workspace_name") or "",
|
|
98
|
+
"expires_at": credentials.get("expires_at"),
|
|
99
|
+
"refreshable": bool(credentials.get("refresh_token")),
|
|
100
|
+
"path": credentials_path}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def summary(info) -> str:
|
|
104
|
+
"""The status-row text for read_profile(): the account email (the row
|
|
105
|
+
carries no other label — the kind header names the service)."""
|
|
106
|
+
who = info.get("email") or "signed in"
|
|
107
|
+
if not info.get("refreshable"):
|
|
108
|
+
who += " · no refresh"
|
|
109
|
+
return who
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def write_profile(profile: str, token: dict, client_id: str,
|
|
113
|
+
base_url: str | None = None, console_url: str | None = None) -> Path:
|
|
114
|
+
"""Persist a /v1/oauth/token response as the SDK profile `profile`.
|
|
115
|
+
The config file (non-secret intent: org, workspace, base_url) is kept
|
|
116
|
+
when it already exists for this client — like `ant`, a re-login never
|
|
117
|
+
rewrites it — and the credentials file (0600, dir 0700) is always
|
|
118
|
+
rewritten. Returns the credentials path."""
|
|
119
|
+
config_path, credentials_path = profile_paths(profile)
|
|
120
|
+
organization = token.get("organization") or {}
|
|
121
|
+
account = token.get("account") or {}
|
|
122
|
+
workspace = token.get("workspace") or {}
|
|
123
|
+
|
|
124
|
+
existing = None
|
|
125
|
+
try:
|
|
126
|
+
existing = json.loads(config_path.read_text(encoding="utf-8"))
|
|
127
|
+
except (OSError, ValueError):
|
|
128
|
+
existing = None
|
|
129
|
+
keep = (isinstance(existing, dict)
|
|
130
|
+
and (existing.get("authentication") or {}).get("type") == "user_oauth"
|
|
131
|
+
and (existing.get("authentication") or {}).get("client_id") == client_id)
|
|
132
|
+
if not keep:
|
|
133
|
+
config = {"version": "1.0",
|
|
134
|
+
"authentication": {"type": "user_oauth", "client_id": client_id}}
|
|
135
|
+
if organization.get("uuid"):
|
|
136
|
+
config["organization_id"] = organization["uuid"]
|
|
137
|
+
if workspace.get("id"):
|
|
138
|
+
config["workspace_id"] = workspace["id"]
|
|
139
|
+
if base_url and base_url.rstrip("/") != DEFAULT_BASE_URL:
|
|
140
|
+
config["base_url"] = base_url.rstrip("/")
|
|
141
|
+
if console_url:
|
|
142
|
+
config["authentication"]["console_url"] = console_url.rstrip("/")
|
|
143
|
+
_atomic_write(config_path, config, file_mode=0o644, dir_mode=0o755)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
expires_in = int(token.get("expires_in") or 3600)
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
expires_in = 3600
|
|
149
|
+
credentials = {"version": "1.0", "type": "oauth_token",
|
|
150
|
+
"access_token": token["access_token"],
|
|
151
|
+
"expires_at": int(time.time()) + expires_in}
|
|
152
|
+
if token.get("refresh_token"):
|
|
153
|
+
credentials["refresh_token"] = token["refresh_token"]
|
|
154
|
+
for key, value in (("scope", token.get("scope")),
|
|
155
|
+
("organization_uuid", organization.get("uuid")),
|
|
156
|
+
("organization_name", organization.get("name")),
|
|
157
|
+
("account_email", account.get("email_address")),
|
|
158
|
+
("workspace_id", workspace.get("id")),
|
|
159
|
+
("workspace_name", workspace.get("name"))):
|
|
160
|
+
if value:
|
|
161
|
+
credentials[key] = value
|
|
162
|
+
_atomic_write(credentials_path, credentials, file_mode=0o600, dir_mode=0o700)
|
|
163
|
+
return credentials_path
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def sign_out(profile: str) -> bool:
|
|
167
|
+
"""Remove the profile's credentials (the config — org/workspace intent —
|
|
168
|
+
stays, so a re-login lands in the same org without the picker; same as
|
|
169
|
+
`ant auth logout`). True when a credentials file was removed."""
|
|
170
|
+
_, credentials_path = profile_paths(profile)
|
|
171
|
+
try:
|
|
172
|
+
credentials_path.unlink()
|
|
173
|
+
return True
|
|
174
|
+
except FileNotFoundError:
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _atomic_write(path: Path, data: dict, file_mode: int, dir_mode: int):
|
|
179
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
try:
|
|
181
|
+
os.chmod(path.parent, dir_mode)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
185
|
+
with open(tmp, "w", encoding="utf-8") as file:
|
|
186
|
+
file.write(json.dumps(data, indent=2))
|
|
187
|
+
file.write("\n")
|
|
188
|
+
os.chmod(tmp, file_mode)
|
|
189
|
+
os.replace(tmp, path)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
193
|
+
# OAuth helpers (RFC 7636 PKCE, authorization_code grant)
|
|
194
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def random_urlsafe(n_bytes: int) -> str:
|
|
197
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(n_bytes)).decode().rstrip("=")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def pkce_challenge(verifier: str) -> str:
|
|
201
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
202
|
+
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_authorize_url(console_url: str, client_id: str, redirect_uri: str, scope: str,
|
|
206
|
+
state: str, challenge: str,
|
|
207
|
+
organization_id: str | None = None, workspace_id: str | None = None) -> str:
|
|
208
|
+
"""The Console consent page. `organization_id` (`?orgUUID=`) makes the
|
|
209
|
+
Console skip its org picker when the signed-in account is a member;
|
|
210
|
+
`workspace_id` skips the workspace picker. Either omitted → picker."""
|
|
211
|
+
params = [("client_id", client_id), ("redirect_uri", redirect_uri), ("response_type", "code"),
|
|
212
|
+
("scope", scope), ("state", state), ("code_challenge", challenge),
|
|
213
|
+
("code_challenge_method", "S256")]
|
|
214
|
+
if workspace_id:
|
|
215
|
+
params.append(("workspace_id", workspace_id))
|
|
216
|
+
if organization_id:
|
|
217
|
+
params.append(("orgUUID", organization_id))
|
|
218
|
+
return f"{console_url.rstrip('/')}/oauth/authorize?{urllib.parse.urlencode(params)}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def exchange_code(base_url: str, client_id: str, code: str, verifier: str, redirect_uri: str,
|
|
222
|
+
state: str, timeout_s: float = 30.0) -> dict:
|
|
223
|
+
"""Redeem the authorization code at /v1/oauth/token. Form-encoded and
|
|
224
|
+
WITHOUT an anthropic-beta header — that is the authorization_code
|
|
225
|
+
grant's route (`state` is required on this leg too: it is the bound
|
|
226
|
+
CSRF check across both legs)."""
|
|
227
|
+
from meltygui.completion.providers.anthropic_requests import notify_request
|
|
228
|
+
notify_request("POST /v1/oauth/token", "browser sign-in code exchange")
|
|
229
|
+
body = urllib.parse.urlencode({"grant_type": "authorization_code", "code": code,
|
|
230
|
+
"code_verifier": verifier, "client_id": client_id,
|
|
231
|
+
"redirect_uri": redirect_uri, "state": state}).encode()
|
|
232
|
+
request = urllib.request.Request(
|
|
233
|
+
f"{base_url.rstrip('/')}/v1/oauth/token", data=body, method="POST",
|
|
234
|
+
headers={"Content-Type": "application/x-www-form-urlencoded",
|
|
235
|
+
"Accept": "application/json", "User-Agent": "latent-descent"})
|
|
236
|
+
try:
|
|
237
|
+
with urllib.request.urlopen(request, timeout=timeout_s) as response:
|
|
238
|
+
raw = response.read()
|
|
239
|
+
except urllib.error.HTTPError as error:
|
|
240
|
+
detail = error.read().decode("utf-8", "replace").strip()
|
|
241
|
+
request_id = error.headers.get("request-id", "") if error.headers else ""
|
|
242
|
+
raise RuntimeError(f"token endpoint returned {error.code}"
|
|
243
|
+
+ (f" (request_id={request_id})" if request_id else "")
|
|
244
|
+
+ (f": {detail[:200]}" if detail else "")) from None
|
|
245
|
+
except urllib.error.URLError as error:
|
|
246
|
+
raise RuntimeError(f"token endpoint unreachable: {error.reason}") from None
|
|
247
|
+
try:
|
|
248
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
249
|
+
except ValueError:
|
|
250
|
+
raise RuntimeError("token endpoint returned a non-JSON response") from None
|
|
251
|
+
if not isinstance(payload, dict) or not payload.get("access_token"):
|
|
252
|
+
raise RuntimeError("token endpoint returned no access_token")
|
|
253
|
+
return payload
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
257
|
+
# The interactive flow
|
|
258
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
261
|
+
"""The loopback redirect target. Only /callback counts (a favicon or a
|
|
262
|
+
stray local request must not read as a failed sign-in); `state` gates
|
|
263
|
+
the code; the FIRST result wins."""
|
|
264
|
+
|
|
265
|
+
def log_message(self, *args): # keep the server's stdout clean
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
def do_GET(self):
|
|
269
|
+
flow = self.server.flow
|
|
270
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
271
|
+
if parsed.path != "/callback":
|
|
272
|
+
self._page(404, "Not found", "")
|
|
273
|
+
return
|
|
274
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
275
|
+
|
|
276
|
+
def param(name):
|
|
277
|
+
return (query.get(name) or [""])[0]
|
|
278
|
+
|
|
279
|
+
if param("error"):
|
|
280
|
+
flow._deliver(error=f"authorization denied: {param('error')}: {param('error_description')}")
|
|
281
|
+
self._page(400, "✗ Sign-in failed", f"{param('error')}: {param('error_description')}")
|
|
282
|
+
elif param("state") != flow.state:
|
|
283
|
+
flow._deliver(error="state mismatch (possible CSRF) — start the sign-in again")
|
|
284
|
+
self._page(400, "✗ State mismatch",
|
|
285
|
+
"The callback state did not match this sign-in attempt. "
|
|
286
|
+
"Do not retry from this browser session.")
|
|
287
|
+
elif not param("code"):
|
|
288
|
+
flow._deliver(error="the authorization callback carried no code")
|
|
289
|
+
self._page(400, "✗ Missing code", "The authorization callback did not include a code.")
|
|
290
|
+
else:
|
|
291
|
+
flow._deliver(code=param("code"))
|
|
292
|
+
self._page(200, "✓ Signed in", "You can close this tab and return to Latent Descent.")
|
|
293
|
+
|
|
294
|
+
def _page(self, status, heading, body):
|
|
295
|
+
content = (f'<html><body style="font-family:system-ui;text-align:center;padding:4em">'
|
|
296
|
+
f'<h2>{html.escape(heading)}</h2><p>{html.escape(body)}</p></body></html>').encode()
|
|
297
|
+
self.send_response(status)
|
|
298
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
299
|
+
self.send_header("Content-Length", str(len(content)))
|
|
300
|
+
self.end_headers()
|
|
301
|
+
self.wfile.write(content)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class LoginFlow:
|
|
305
|
+
"""One browser sign-in. `start()` binds the loopback port, builds the
|
|
306
|
+
consent URL (`url` — the window's Copy link / Open browser), opens the
|
|
307
|
+
browser and returns; a worker then waits for the redirect, exchanges
|
|
308
|
+
the code and writes the profile. Read `done` / `error` / `result`
|
|
309
|
+
(read_profile() of the new login); `on_change(flow)` fires from the
|
|
310
|
+
worker on every transition. `cancel()` ends the wait within
|
|
311
|
+
`poll_s`."""
|
|
312
|
+
|
|
313
|
+
def __init__(self, profile: str, *, client_id: str | None = None, scope: str | None = None,
|
|
314
|
+
console_url: str | None = None, base_url: str | None = None,
|
|
315
|
+
organization_id: str | None = None, workspace_id: str | None = None,
|
|
316
|
+
open_browser: bool = True, timeout_s: float | None = None, on_change=None):
|
|
317
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
318
|
+
self.profile = profile
|
|
319
|
+
self.client_id = client_id or Toggles.InternetAccounts.anthropic_oauth_client_id
|
|
320
|
+
self.scope = scope or Toggles.InternetAccounts.anthropic_oauth_scope
|
|
321
|
+
self.console_url = (console_url or Toggles.InternetAccounts.anthropic_console_url).rstrip("/")
|
|
322
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
323
|
+
self.organization_id = organization_id
|
|
324
|
+
self.workspace_id = workspace_id
|
|
325
|
+
self.open_browser = open_browser
|
|
326
|
+
self.timeout_s = (Toggles.InternetAccounts.anthropic_login_timeout_s
|
|
327
|
+
if timeout_s is None else timeout_s)
|
|
328
|
+
self.on_change = on_change
|
|
329
|
+
# How often the worker re-checks cancel / timeout between redirects.
|
|
330
|
+
self.poll_s = 0.5
|
|
331
|
+
|
|
332
|
+
self.verifier = random_urlsafe(48)
|
|
333
|
+
self.state = random_urlsafe(24)
|
|
334
|
+
self.redirect_uri = None
|
|
335
|
+
self.url = None
|
|
336
|
+
self.error = None
|
|
337
|
+
self.result = None
|
|
338
|
+
self.done = False
|
|
339
|
+
self.started_at = None
|
|
340
|
+
self._popup = None
|
|
341
|
+
self._server = None
|
|
342
|
+
self._thread = None
|
|
343
|
+
self._code = None
|
|
344
|
+
self._callback_error = None
|
|
345
|
+
self._got = threading.Event()
|
|
346
|
+
self._cancelled = threading.Event()
|
|
347
|
+
|
|
348
|
+
# -- public -----------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
def start(self) -> str:
|
|
351
|
+
if self.organization_id is None:
|
|
352
|
+
self.organization_id = self._stored_organization_id()
|
|
353
|
+
self._server = HTTPServer(("127.0.0.1", 0), _CallbackHandler)
|
|
354
|
+
self._server.flow = self
|
|
355
|
+
self._server.timeout = self.poll_s
|
|
356
|
+
port = self._server.server_address[1]
|
|
357
|
+
# `localhost` in the redirect (the callback form), bound on 127.0.0.1 -
|
|
358
|
+
# the same pairing the CLI uses.
|
|
359
|
+
self.redirect_uri = f"http://localhost:{port}/callback"
|
|
360
|
+
self.url = build_authorize_url(self.console_url, self.client_id, self.redirect_uri,
|
|
361
|
+
self.scope, self.state, pkce_challenge(self.verifier),
|
|
362
|
+
organization_id=self.organization_id,
|
|
363
|
+
workspace_id=self.workspace_id)
|
|
364
|
+
self.started_at = time.monotonic()
|
|
365
|
+
self._thread = threading.Thread(target=self._run, daemon=True,
|
|
366
|
+
name=f"anthropic-login-{self.profile}")
|
|
367
|
+
self._thread.start()
|
|
368
|
+
if self.open_browser:
|
|
369
|
+
self.open_in_browser()
|
|
370
|
+
return self.url
|
|
371
|
+
|
|
372
|
+
def open_in_browser(self) -> bool:
|
|
373
|
+
if not self.url:
|
|
374
|
+
return False
|
|
375
|
+
import meltygui.completion.providers.oauth_popup as oauth_popup
|
|
376
|
+
popup = oauth_popup.open_auth_popup(self.url) # falls back to xdg-open itself
|
|
377
|
+
if popup is not None:
|
|
378
|
+
self._popup = popup
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
def cancel(self):
|
|
382
|
+
self._cancelled.set()
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def alive(self) -> bool:
|
|
386
|
+
return self._thread is not None and not self.done
|
|
387
|
+
|
|
388
|
+
# -- worker -----------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def _deliver(self, code=None, error=None):
|
|
391
|
+
if self._got.is_set():
|
|
392
|
+
return # first request wins (browser prefetch, reloads)
|
|
393
|
+
self._code, self._callback_error = code, error
|
|
394
|
+
self._got.set()
|
|
395
|
+
|
|
396
|
+
def _run(self):
|
|
397
|
+
deadline = time.monotonic() + self.timeout_s
|
|
398
|
+
try:
|
|
399
|
+
while (not self._got.is_set() and not self._cancelled.is_set()
|
|
400
|
+
and time.monotonic() < deadline):
|
|
401
|
+
self._server.handle_request()
|
|
402
|
+
except Exception as error:
|
|
403
|
+
self._callback_error = f"callback listener failed: {error}"
|
|
404
|
+
self._got.set()
|
|
405
|
+
finally:
|
|
406
|
+
try:
|
|
407
|
+
self._server.server_close()
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
if self._cancelled.is_set():
|
|
411
|
+
self.error = "sign-in cancelled"
|
|
412
|
+
elif not self._got.is_set():
|
|
413
|
+
self.error = "timed out waiting for the browser sign-in"
|
|
414
|
+
elif self._callback_error:
|
|
415
|
+
self.error = self._callback_error
|
|
416
|
+
else:
|
|
417
|
+
try:
|
|
418
|
+
token = exchange_code(self.base_url, self.client_id, self._code, self.verifier,
|
|
419
|
+
self.redirect_uri, self.state)
|
|
420
|
+
write_profile(self.profile, token, self.client_id,
|
|
421
|
+
base_url=self.base_url, console_url=self.console_url)
|
|
422
|
+
self.result = read_profile(self.profile)
|
|
423
|
+
except Exception as error:
|
|
424
|
+
self.error = f"sign-in failed: {error}"
|
|
425
|
+
self.done = True
|
|
426
|
+
if self._popup is not None:
|
|
427
|
+
self._popup.close() # the flow is over - take the popup with it
|
|
428
|
+
self._notify()
|
|
429
|
+
|
|
430
|
+
def _notify(self):
|
|
431
|
+
if self.on_change is not None:
|
|
432
|
+
try:
|
|
433
|
+
self.on_change(self)
|
|
434
|
+
except Exception:
|
|
435
|
+
pass
|
|
436
|
+
|
|
437
|
+
def _stored_organization_id(self):
|
|
438
|
+
"""The org a previous login of this profile bound (config file) —
|
|
439
|
+
passed as the Console's ?orgUUID hint so a re-login skips the org
|
|
440
|
+
picker, like `ant`."""
|
|
441
|
+
try:
|
|
442
|
+
config_path, _ = profile_paths(self.profile)
|
|
443
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
444
|
+
return config.get("organization_id") or None
|
|
445
|
+
except (OSError, ValueError, AttributeError):
|
|
446
|
+
return None
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Every Anthropic API request the studio makes announces itself — a toast
|
|
2
|
+
in the notifications overlay (tag "anthropic"; repeats while visible
|
|
3
|
+
coalesce into a count) plus a console line — so "who is hitting the API"
|
|
4
|
+
is always visible (born from an /api/oauth/usage rate-limit hunt, 08-25).
|
|
5
|
+
Gate: Toggles.InternetAccounts.notify_requests.
|
|
6
|
+
|
|
7
|
+
Two hooks cover the studio's traffic:
|
|
8
|
+
- `notify_request(what, detail=)` — hand-written call sites: the usage
|
|
9
|
+
fetch, the browser sign-in's token exchange, launching Claude Code's
|
|
10
|
+
`claude auth login` (that one is Claude Code's own traffic, labelled so).
|
|
11
|
+
- `sdk_middleware()` — anthropic SDK middleware for
|
|
12
|
+
`Anthropic(middleware=[…])` (ClaudeSession and the Test button pass it):
|
|
13
|
+
the chain runs once per HTTP ATTEMPT inside the SDK's retry loop, so FIM
|
|
14
|
+
completions, Test, AND every retry announce with method + path, and a
|
|
15
|
+
non-2xx attempt is called out with its status ("429 · rate limited").
|
|
16
|
+
|
|
17
|
+
Known gap: the SDK's own profile token refresh (anthropic/lib/credentials,
|
|
18
|
+
its private httpx client) doesn't run through client middleware.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def enabled() -> bool:
|
|
26
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
27
|
+
return bool(Toggles.InternetAccounts.notify_requests)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def notify_request(what, detail=""):
|
|
31
|
+
"""One announced request: console line + overlay toast (never raises,
|
|
32
|
+
never imports the SDK)."""
|
|
33
|
+
if not enabled():
|
|
34
|
+
return
|
|
35
|
+
text = what + (f" · {detail}" if detail else "")
|
|
36
|
+
print(f"[anthropic] {time.strftime('%H:%M:%S')} {text}")
|
|
37
|
+
try:
|
|
38
|
+
from meltygui.core.diagnostics.notifications import notify
|
|
39
|
+
notify(text, tint=(0.85, 0.55, 0.35, 1.0), tag="anthropic")
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_sdk_middleware = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sdk_middleware():
|
|
48
|
+
"""The (cached) middleware instance for `anthropic.Anthropic(middleware=
|
|
49
|
+
[sdk_middleware()])`. Built lazily so importing this module never pays
|
|
50
|
+
the anthropic import."""
|
|
51
|
+
global _sdk_middleware
|
|
52
|
+
if _sdk_middleware is None:
|
|
53
|
+
from anthropic import Middleware
|
|
54
|
+
|
|
55
|
+
class RequestNotifier(Middleware):
|
|
56
|
+
def handle(self, request, call_next):
|
|
57
|
+
url = getattr(request, "url", None)
|
|
58
|
+
path = getattr(url, "path", "") or str(url or "?")
|
|
59
|
+
method = getattr(request, "method", "?")
|
|
60
|
+
notify_request(f"{method} {path}")
|
|
61
|
+
response = call_next(request)
|
|
62
|
+
status = getattr(response, "status_code", None)
|
|
63
|
+
if status is not None and status >= 400:
|
|
64
|
+
notify_request(f"{method} {path} → {status}",
|
|
65
|
+
"rate limited" if status == 429 else "")
|
|
66
|
+
return response
|
|
67
|
+
|
|
68
|
+
_sdk_middleware = RequestNotifier()
|
|
69
|
+
return _sdk_middleware
|