diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..54d0820 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,60 @@ +# CodeRabbit configuration +# Docs: https://docs.coderabbit.ai/getting-started/configure-coderabbit-overview +language: "en-GB" +early_access: false + +reviews: + profile: "assertive" + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + commit_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false + path_instructions: + - path: "converter.py" + instructions: | + Review with thermo-nuclear standards: implementation quality, abstraction quality and codebase health, not just local nits. Prioritise in this order: structural regressions, missed dramatic simplifications ("code judo" reframings that delete whole branches or layers), spaghetti/branching growth, boundary and type-contract problems, file-size and decomposition, modularity, legibility. + + This file is the whole proxy and sits near the 1000-line threshold. Flag any change that pushes it past 1000 lines, and ask whether the code should be decomposed into modules first. + + Flag: ad-hoc conditionals bolted onto unrelated flows, one-off flags that complicate control flow, feature logic leaking into shared paths, thin wrappers that add indirection without clarity, unnecessary casts or Optional plumbing, duplicated logic where a canonical helper exists, unnecessary sequential orchestration or non-atomic updates. + + Domain-specific checks: + - Credential handling must not put secrets in argv, log output, error responses, or crash output. Any change that touches key or token resolution gets this check explicitly. + - The translation layer between the OpenAI request shape and the upstream shape must preserve exact wire semantics. A change described as a refactor that alters a verb, a header, a field name, or a streaming frame boundary is not a refactor. + - Streaming reassembly and tool_calls mapping are the highest-risk paths. Confirm a change there has a test that exercises partial frames and out-of-order chunks, not only the happy path. + - Error handling must sit at the layer that owns the invariant. A widened except clause that swallows a domain error one layer too deep is a defect even when it makes a test pass. + + A test suite that has never been observed failing is not a safety net. Where a change relies on existing tests for behaviour preservation, ask whether those tests were seen red against the old code. + + Do not approve merely because behaviour seems correct: the bar is no structural regression, no obvious missed simplification, no spaghetti growth, and no credential exposure. Prefer a few high-conviction comments over cosmetic nits. Be direct and demanding about quality, but not rude. + - path: "tests/**" + instructions: | + Tests assert behaviour, not the shape of the source. Reject a test that reads a source file's text, that freezes a current count or version, or that passes both before and after the change it claims to cover. + + For a change described as a refactor, the test must exercise the old code path as well, so a regression is observable. Assert exact wire tuples (verb, path, params, body) for anything touching transport, not a loose shape match. + + Prefer assertion lists over sets unless order genuinely does not matter. + - path: "**/*.md" + instructions: | + Documentation is reviewed for accuracy against the code, not for style alone. Any sentence naming a flag, a path, an endpoint, an exit code or a count must match the implementation. Flag a claim the code contradicts. + + Spelling is en-GB. Identifiers, commands and JSON keys keep their original form. + - path: "*.sh" + instructions: | + Shell scripts are executable text, not prose. Do not suggest typography changes to quoting; a double quote in shell is an operator. + + Check that no secret reaches argv, that the script fails loudly rather than continuing on a missing dependency, and that interpreter resolution cannot silently select a system Python that lacks the project dependencies. + - path: "*.bat" + instructions: | + Windows launchers must resolve the same interpreter and the same dependency set as start.sh. Flag any divergence in behaviour between a .bat launcher and the shell equivalent. + - path: ".gitignore" + instructions: | + Confirm secrets stay ignored (.env, tokens, keys) and that no build or cache artifact is newly tracked. + +chat: + auto_reply: true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3fe405d --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Copy to .env and fill in the values you need. +# +# The launcher loads this file automatically. Nothing here is required if you +# pass the equivalent command line flag instead. + +# API key the proxy requires from its clients. +# Equivalent to --api-key. Leave unset to run with no authentication. +# WORKBUDDY2OPENAI_KEY=c2o-local-1 + +# WorkBuddy / CodeBuddy API key, for the direct-key mode that skips the desktop +# session entirely. +# Equivalent to --direct-key. +# Generate one at https://www.codebuddy.ai/profile/keys +# WORKBUDDY_DIRECT_KEY=ck_yourkeyhere + +# Path to write the proxy log to. +# Equivalent to --log. Leave unset to log to the console only. +# WORKBUDDY2OPENAI_LOG=c2o.log + +# The CODEBUDDY_* spellings of the three variables above still work, so an +# existing .env file needs no changes. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8a7d4fa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,28 @@ +# Normalise line endings so the shell and Windows launchers stay runnable. +* text=auto + +*.sh text eol=lf +*.bat text eol=crlf +*.py text eol=lf +*.md text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.toml text eol=lf +*.ini text eol=lf +*.txt text eol=lf +.env.example text eol=lf +.gitattributes text eol=lf +.gitignore text eol=lf + +# Binary assets, never diffed or merged as text. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary + +# Generated and dependency artifacts, excluded from language statistics. +.venv/** linguist-vendored +__pycache__/** linguist-generated +*.pyc linguist-generated diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1036b3e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,46 @@ +--- +name: Bug report +about: Something does not work +labels: bug +--- + +## What happens + + + +## What you expected + + + +## Steps to reproduce + +1. +2. +3. + +## Environment + +- OS and version: +- Python version (`python --version`): +- Install method (git clone, pipx, other): +- Commit or version: + +## Proxy output + + + +``` + +``` + +## Request and response + + + +``` + +``` + +## Anything else + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..28e2930 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest a change +labels: enhancement +--- + +## The problem + + + +## What you would like + + + +## Alternatives you considered + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ca6daea --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## What this changes + + + +## Why + + + +## How you verified it + + + +- [ ] Full suite passes +- [ ] Exercised against a running proxy (if this touches request handling, streaming, or credential resolution) diff --git a/.gitignore b/.gitignore index 9176b83..a1b2a47 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ Thumbs.db *.token *.key secrets.* +.env +.env.* +!.env.example + +# tests +.pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d1a4878 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +Notable changes to this fork. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +This fork is maintained at https://github.com/leonid-dalin/codebuddy2openai and is not released in step with the upstream project. + +## [Unreleased] + +### Added + +- `SECURITY.md`, with the threat model for a proxy that holds a live account credential and binds to loopback by default. +- `CONTRIBUTING.md`, covering setup, tests, end-to-end verification, and commit conventions. +- `THIRD_PARTY_NOTICES.md`, listing the declared dependencies with their licences. +- `CODE_OF_CONDUCT.md`, adapted from Contributor Covenant 3.0. +- `.env.example`, documenting every environment variable the code reads. +- `.gitattributes`, pinning line endings per file type. +- `.coderabbit.yaml`, configuring automated review with maintainability and credential-safety checks. +- `pyproject.toml`, so the project installs with `pip install .` and exposes a `workbuddy2openai` console command. +- Issue and pull request templates. + +### Changed + +- The project is licensed under GPL-3.0-or-later. The upstream MIT terms stay in force for the upstream code and are reproduced in `LICENSE`. +- The README is split by language: `README.md` holds the English documentation and `README.zh-CN.md` holds the Chinese documentation, each linking to the other. +- Environment variables accept the `WORKBUDDY_*` names. The `CODEBUDDY_*` names still work, so existing `.env` files keep running. + +### Fixed + +- `.gitignore` covered only a bare `.env`, leaving `.env.local` and similar files untracked-but-committable. It now ignores `.env.*` and keeps `.env.example`. + +## [2.0.0] + +### Added + +- API key mode (`--direct-key`), which skips the desktop session and calls the international backend with a `CK_*` key. This is the path for WorkBuddy international accounts, where the desktop token path returns 401. +- `/health` endpoint reporting platform, Python version, and mode. + +### Notes + +- Streaming is always used upstream; non-streaming client requests are aggregated by the proxy. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..fcbd333 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant 3.0 Code of Conduct + +## Our pledge + +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, colour, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant. + +## Encouraged behaviours + +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behaviour. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that centre our shared values, including: + +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. + +## Restricted behaviours + +We agree to restrict the following behaviours in our community. Instances, threats, and promotion of these behaviours are violations of this Code of Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people. +3. **Stereotyping or discrimination.** Characterising anyone's personality or behaviour on the basis of immutable identities or traits. +4. **Sexualisation.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community. +5. **Violating confidentiality.** Sharing or acting on someone's personal or private information without their permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. + +### Other restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials.** Sharing marketing or other commercial content in a way that is outside the norms of the community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviours. + +## Reporting an issue + +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviours and norms that can help avoid conflicts and minimise harm. + +When an incident does occur, it is important to report it promptly. To report a possible violation, email **infoleonid@protonmail.com**. Reports are read by the project maintainer. + +Community moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community moderators will keep investigation and enforcement actions as transparent as possible while prioritising safety and confidentiality. To honour these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution. + +## Addressing and repairing harm + +If an investigation by the community moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped. + +1. Warning + 1. Event: A violation involving a single incident or series of incidents. + 2. Consequence: A private, written warning from the community moderators. + 3. Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations. +2. Temporarily limited activities + 1. Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation. + 2. Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members. + 3. Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over. +3. Temporary suspension + 1. Event: A pattern of repeated violation which the community moderators have tried to address with warnings, or a single serious violation. + 2. Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behaviour and possible corrective actions. + 3. Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4. Permanent ban + 1. Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the community moderators determine there is no way to keep the community safe with this person as a member. + 2. Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behaviour. + 3. Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability of community managers to use their discretion and judgment, in keeping with the best interests of our community. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this licence, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) + +For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla's code of conduct team](https://github.com/mozilla/inclusion). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9fc6d7c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,97 @@ +# Contributing + +Thanks for considering a change. This document covers the practical steps. + +## Before you start + +Open an issue for anything larger than a bug fix. A short description of the problem and the approach saves a rewrite. Small fixes can go straight to a pull request. + +Check whether an existing issue or pull request already covers the change. + +## Setting up + +One dependency file covers running the proxy, and a second adds the test tooling. + +``` +python -m venv .venv +.venv/bin/python -m pip install -r requirements-dev.txt +``` + +On Windows the interpreter lives at `.venv\Scripts\python.exe`. + +`requirements.txt` holds the runtime dependencies. `requirements-dev.txt` includes it and adds pytest. + +## Running the tests + +``` +.venv/bin/python -m pytest +``` + +Write a test for every behaviour change. A fix that a test cannot observe will regress the next time somebody refactors the surrounding code. + +For a change that only restructures existing code, write the test against the current code first, watch it pass, make the change, and confirm it still passes. A restructuring that also alters the wire format is not a restructuring, so split it into two changes. + +## Running the proxy + +``` +./start.sh +``` + +The launcher loads `.env`, picks the project virtual environment, and installs the runtime dependencies if they are missing. The Windows launchers `start.bat` and `start-if-needed.bat` do the equivalent. + +Verify a change end to end rather than only through the test suite. Start the proxy and send it a real completion request: + +``` +curl -X POST http://127.0.0.1:8787/v1/chat/completions \ + -H "Authorization: Bearer $YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"hy3","messages":[{"role":"user","content":"ping"}]}' +``` + +A passing unit test confirms the code compiles and the logic holds. It does not confirm the upstream service still answers. + +## Code style + +- Match the surrounding code. The project has few files and a consistent shape; keep it that way. +- No comments. A comment earns its place only when it records something the code cannot express: a non-obvious runtime behaviour, a deliberate ordering, or a hazard with no visible marker. If the line below already says it, delete the comment. +- Keep functions small and put logic in the module that owns the concept. +- Prefer plain code over indirection. A helper that only forwards its arguments adds a layer without adding clarity, so inline it. + +## Commit messages + +Use Conventional Commits: `(): `. The types in use are feat, fix, refactor, perf, style, test, docs, build, ops, and chore. + +Write the description in the imperative present tense, with no leading capital and no trailing period. For example: + +``` +feat: add API key mode for WorkBuddy international accounts +fix: read the credential file when the CLI is absent +``` + +Keep one logical change per commit. Open a separate pull request for unrelated work. + +The body explains why the change exists. Two sentences is usually enough, and the diff already shows what changed. + +## Pull requests + +1. Branch from the default branch. +2. Make the change, with tests. +3. Run the full suite and confirm it passes. +4. Verify the change against a running proxy if it touches request handling, streaming, or credential resolution. +5. Describe the change and its motivation in the pull request body. + +Say what you verified and how. A note that you exercised the real endpoint carries more weight than a note that the tests pass. + +## Reporting bugs + +Include the platform, the Python version, the proxy startup output, and the smallest set of steps that reproduces the problem. If the proxy returned an error, include the response body and the matching lines from the log. + +Redact your API key and any token from anything you paste. + +## Security issues + +Do not open a public issue. Follow [SECURITY.md](SECURITY.md). + +## Code of conduct + +Participation is covered by [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). diff --git a/LICENSE b/LICENSE index b2aafa9..69aa26e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,46 @@ +workbuddy2openai: a local OpenAI-compatible proxy for the CodeBuddy / WorkBuddy desktop client +Copyright (C) 2026 Leonid Dalin + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +--- + +## Provenance + +This repository is a fork of +https://github.com/HanHan666666/codebuddy2openai + +The upstream project is licensed under the MIT Licence, +copyright (c) 2026 HanHan666666. Those terms are reproduced at the end of this +file and remain in force for the upstream code. + +Licence relationship: + +- The upstream MIT-licensed code stays available under MIT. +- Changes and additions made in this fork are licensed under the GNU General + Public License, version 3 or later. +- The combined work is distributed under GPL-3.0-or-later, which the MIT terms + permit. The MIT terms are not replaced or revoked by this relicensing. +- Section 5 requires a modified work to carry prominent notices stating that it + was modified and giving a relevant date. This section records that notice. + +Both the project name and the upstream project name are retained here for +provenance. The fork is maintained independently, and the upstream maintainer +does not review or endorse changes made here. + +--- + MIT License Copyright (c) 2026 HanHan666666 @@ -19,3 +62,685 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +--- + +# GNU GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 3a90722..477afc3 100644 --- a/README.md +++ b/README.md @@ -1,227 +1,66 @@ -# codebuddy2openai +# workbuddy2openai -> 把 **CodeBuddy / WorkBuddy(腾讯代码助手)** 的订阅,转换成 **OpenAI 兼容 API**,让你能在任何支持 OpenAI 协议的客户端(ZCode、Cherry Studio、NextChat、LobeChat 等)里复用它。 +> Turn your **CodeBuddy / WorkBuddy (Tencent coding assistant)** subscription into a standard **OpenAI-compatible API**, so you can reuse it from any OpenAI-protocol client (ZCode, Cherry Studio, NextChat, LobeChat, Open WebUI, and others). -> ⚠️ **关于 Codex CLI**:新版 Codex CLI 已不再支持 `wire_api = "chat"`,只支持 `completions` 格式,因此**本工具无法直接接入 Codex CLI**,请改用下方「OpenAI 兼容客户端」方案。 - -[English](#english) · [中文文档](#中文文档) - ---- - -## 中文文档 - -一个极简的本地协议转换器(proxy / adapter):读取你本机已登录的 CodeBuddy 桌面端凭据,把它的对话能力包装成标准的 OpenAI `/v1/chat/completions`、`/v1/models` 接口。**不碰登录授权、不碰你已有的客户端配置、跨平台、单文件。** - -### ✨ 特性 - -- 🔄 **OpenAI 兼容**:标准 `/v1/chat/completions`(支持流式 SSE)、`/v1/models`、`/health`。 -- 🛠️ **Function Calling(工具调用)**:支持请求里的 `tools`,返回 OpenAI 格式的 `tool_calls`,可在 ZCode / Cherry Studio 等 agent 客户端里驱动工具、多轮回传结果。 -- 🪶 **单文件、极简**:核心就一个 `converter.py`,不复杂。 -- 🔐 **零授权改动**:直接调用本机已登录的 `codebuddy` CLI,自动复用桌面端登录态,不重新登录、不存密码。 -- 🖥️ **跨平台**:自动定位 macOS / Windows / Linux 上的 CLI 与登录文件。 -- 🛡️ **安全**:默认只监听 `127.0.0.1`;工具的声明与执行都由客户端负责,转换器只做鉴权与透传。 -- ⚡ **流式输出**:实时增量 token,体验与原生 OpenAI 流式一致。 - -### 🧠 它是怎么工作的 - -``` -ZCode / Cherry Studio / 任意 OpenAI 客户端 - │ POST /v1/chat/completions (标准 OpenAI 协议,含 tools) - ▼ -┌────────────────────┐ -│ converter.py │ ← 本地 FastAPI 服务 (127.0.0.1:8787) -│ 读 token + 注入 │ -│ 鉴权 header + 透传│ -└────────────────────┘ - │ POST /v2/chat/completions (带 Authorization/X-User-Id 等头) - ▼ -┌────────────────────────────────┐ -│ copilot.tencent.com 后端 │ ← 原生标准 OpenAI 协议 -│ (GLM-5.2 / Kimi / DeepSeek) │ 含原生 tools / tool_calls / SSE 流式 -└────────────────────────────────┘ -``` - -转换器直连 CodeBuddy 后端(`copilot.tencent.com/v2/chat/completions`),该后端本身就是**标准 OpenAI chat/completions 协议**。转换器只做两件事:①读取本机登录凭据并注入鉴权 header;②在本地 `/v1/*` 与后端 `/v2/*` 之间透传。因为后端原生支持 `tools` / `tool_calls`,function calling 是模型自带能力,**无需任何 prompt 注入或文本解析**。token 过期时转换器会自动调刷新接口并回写。 +> ⚠️ **Codex CLI note:** newer Codex CLI dropped `wire_api = "chat"` and only supports the `completions` format, so **this tool cannot be used with Codex CLI**. Use any OpenAI-compatible client instead. -> 历史版本曾通过「调 CLI + `` 文本标签解析」实现 function calling,但在嵌套 agent(subagent)场景下,subagent 的输出会夹带标签污染对话。**v2.0 改为直连后端,彻底解决了这个问题。** +[English](README.md) · [中文文档](README.zh-CN.md) -### 📦 前置条件 +### ✨ Features -1. 已安装并**登录** CodeBuddy / WorkBuddy 桌面端([腾讯云 CodeBuddy 官网](https://www.codebuddy.ai/))。转换器会自动在这些位置找登录态: - - **macOS**:`~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/*.info` - - **Windows**:`%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\*.info` - - **Linux**:`~/.local/share/CodeBuddyExtension\Data\Public\auth\*.info` -2. **Python 3.8+**(无需 Node.js,不再依赖 CLI)。 -3. 安装依赖(一次性): - ```bash - pip install fastapi "uvicorn[standard]" httpx - ``` +- 🔄 **OpenAI-compatible**: standard `/v1/chat/completions` (streaming SSE), `/v1/models`, `/health`. +- 🪶 **Single-file & minimal**: core is one `converter.py`. +- 🔐 **Zero-auth hassle**: calls your locally-logged-in `codebuddy` CLI; reuses the desktop login session. +- 🖥️ **Cross-platform**: auto-locates CLI & auth on macOS / Windows / Linux. +- 🛡️ **Safe**: listens on `127.0.0.1` only; disables all built-in CLI tools for pure chat. -### 🚀 快速开始 +### 🚀 Quick Start ```bash -# 1. 克隆 git clone https://github.com/HanHan666666/codebuddy2openai.git cd codebuddy2openai - -# 2. 装依赖 -pip install fastapi "uvicorn[standard]" httpx - -# 3. 启动(确保 CodeBuddy 桌面端已登录) +pip install -r requirements.txt python3 converter.py -# 看到「✅ 监听 http://127.0.0.1:8787」即成功 ``` -启动时会做一次预检,打印账号信息和 token 状态。 - -### 🛠️ Function Calling(工具调用) - -后端原生支持标准 OpenAI function calling。客户端(如 ZCode / Cherry Studio)在请求里带 `tools`,模型原生返回 `tool_calls`(`finish_reason:"tool_calls"`),客户端执行工具后把 `role:"tool"` 的结果回传即可——和直连 OpenAI 完全一致。流式、非流式、多轮工具调用都支持。 - -### 🔌 接入客户端 - -⚠️ **关于 Codex CLI(重要)**:新版本 Codex CLI 已**移除** `wire_api = "chat"` 的支持,目前只认 `completions` 格式,因此**本转换器无法直接接入 Codex CLI**。仓库里的 `codex-codebuddy.example.toml` 仅作历史/参考保留,实测在当前 Codex 上跑不通,请不要照抄。 - -✅ **可用方式 —— 任何标准 OpenAI 兼容客户端**(走 `/v1/chat/completions`)。常见选择: - -- **ZCode**(OpenAI 兼容 Agent) -- **Cherry Studio** -- **NextChat / LobeChat / Open WebUI** -- 任何支持自定义 `base_url` 的 OpenAI SDK 客户端 - -通用接入步骤(以这类客户端为例): - -1. 保持转换器运行:`python3 converter.py` -2. 在客户端的「自定义模型 / OpenAI 兼容」设置里: - - **API Base / 接口地址**:`http://127.0.0.1:8787/v1` - - **API Key**:留空(转换器默认不校验);若启动时用了 `--api-key`,则填同一个 - - **模型名**:`glm-5.2`(或 `kimi-k2.7` / `deepseek-v4-pro` / `auto` 等,见下方列表) - -示例配置(如果你用的客户端读 toml / 自定义 provider 片段): - -```toml -[model_providers.codebuddy] -name = "CodeBuddy (via local converter)" -base_url = "http://127.0.0.1:8787/v1" -env_key = "CODEBUDDY2OPENAI_KEY" -# 注意:本接口是 OpenAI chat 协议(/v1/chat/completions)。 -# Codex CLI 因不再支持该 wire_api 而无法使用,请用 ZCode / Cherry Studio 等 OpenAI 兼容客户端。 -``` +Then point your OpenAI-compatible client at `http://127.0.0.1:8787/v1` (API base), leave the key blank unless you started the converter with `--api-key`. Note: Codex CLI is **not** supported (it dropped `wire_api = "chat"`); use ZCode, Cherry Studio, or any OpenAI-compatible client instead. -### 🧪 curl 验证 +### Running the tests ```bash -# 列模型 -curl http://127.0.0.1:8787/v1/models - -# 非流式 -curl http://127.0.0.1:8787/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{"model":"glm-5.2","messages":[{"role":"user","content":"你好"}]}' - -# 流式 -curl -N http://127.0.0.1:8787/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"数1到5"}]}' -``` - -### 🤖 可用模型 - -`glm-5.2`、`glm-5.1`、`glm-5v-turbo`、`kimi-k2.7`、`kimi-k2.6`、`kimi-k2.5`、`deepseek-v4-pro`、`deepseek-v4-flash`、`minimax-m3-pay`、`hy3-preview-agent`、`auto` - -(来自 CLI `--help` 的 `--model` 说明,具体可用性以你的订阅为准。) - -### 📁 项目结构 - -``` -codebuddy2openai/ -├── converter.py # 转换器主程序(单文件) -├── desensitize.py # 脱敏模块(可选,--desensitize 启用) -├── codex-codebuddy.example.toml # provider 配置示例片段(仅供参考;Codex CLI 已不支持,见上方说明) -├── README.md -└── LICENSE +pip install -r requirements-dev.txt +pytest ``` -### 🔧 命令行参数 +### API key mode (`--direct-key`) -``` -python3 converter.py [--host HOST] [--port PORT] [--api-key KEY] [--log PATH] [--desensitize] [--skip-check] -``` +WorkBuddy international accounts (Keycloak realm on `www.workbuddy.ai`) get 401s from the desktop-token path: the token shape does not match the backend the converter calls, and refresh fails with `invalid_grant`. If that is your situation, skip the desktop session entirely and use a **CK_\* API key** instead (generate one at [codebuddy.ai/profile/keys](https://www.codebuddy.ai/profile/keys); the CLI documents the same key as `CODEBUDDY_API_KEY`). -| 参数 | 默认 | 说明 | -|------|------|------| -| `--host` | `127.0.0.1` | 监听地址 | -| `--port` | `8787` | 监听端口 | -| `--api-key` | 无 | 启用鉴权;客户端需带同样 key(也可用环境变量 `CODEBUDDY2OPENAI_KEY`)| -| `--log` | 无 | **开启日志并写到该文件**(如 `--log converter.log`)。不传则不记。也可用环境变量 `CODEBUDDY2OPENAI_LOG`。| -| `--desensitize` | 关 | 启用脱敏:对 system 消息里的合规声明敏感词(DoS/exploit/credential/C2 等)插入零宽空格,缓解被后端内容审核误拦(见下方 FAQ)。| -| `--skip-check` | 否 | 跳过启动预检 | - -示例: ```bash -python3 converter.py --log converter.log # 记日志到当前目录 converter.log -python3 converter.py --log /tmp/cb.log # 记到指定路径 -python3 converter.py # 不记日志 -``` - -每条日志记录:模型、是否流式、消息数、最后一条用户提问、耗时、finish_reason、工具调用、token 数;若后端内容审核拦截会标 `⚠️内容审核拦截`。**每次请求都用唯一 ID 串起来,并完整落盘**:发往后端的完整请求体(REQUEST BODY)、后端返回的完整内容(非流式是聚合后的 RESPONSE BODY,流式是后端原始的 RESPONSE RAW SSE)。排查"内容审核拦截""返回异常"等问题时,直接看日志里对应 ID 的完整报文即可。示例: -``` -[2026-06-19 11:56:32] [9cc4488e] ▶ REQUEST glm-5.2 | stream=False | msgs=1 | last_user='Reply: pong' -[2026-06-19 11:56:32] [9cc4488e] ── REQUEST BODY ── -{ "model": "glm-5.2", "messages": [{"role":"user","content":"Reply: pong"}] } -[2026-06-19 11:56:35] [9cc4488e] ◀ RESPONSE glm-5.2 | 3.0s | finish=stop | tokens=11 -[2026-06-19 11:56:35] [9cc4488e] ── RESPONSE BODY ── -{ "choices":[{"message":{"content":"pong"},...}], "usage":{...} } +python3 converter.py --direct-key ck_yourkeyhere +# or: export CODEBUDDY_DIRECT_KEY=ck_yourkeyhere ``` -### ❓ 常见问题 - -- **找不到登录文件**:在桌面端完成登录(不是只装、要登进去)。路径见上方「前置条件」。 -- **客户端报 401**:转换器若用了 `--api-key`,客户端那边要带同样的 key;若是后端 401,可能是 token 失效(转换器会自动刷新,若仍失败需在桌面端重新登录)。 -- **响应慢**:可换 `deepseek-v4-flash` 等更快的模型。 -- **"敏感内容"被拦截**:这是 CodeBuddy 后端的**内容审核**(腾讯合规策略),在模型推理之前就拦了。常见触发原因是客户端注入的 system prompt 里含安全相关英文术语(如 DoS / exploit / credential / C2 等——这些往往是客户端**合规声明模板**里的"拒绝作恶"措辞,属误伤)。两种应对:①用 `--log xxx.log` 在日志里看 `⚠️内容审核拦截` 标记定位是哪条请求;②加 `--desensitize` 启用脱敏模块(`desensitize.py`),它对 system 消息里的这类合规词插入零宽空格(人/模型读无差别,但后端关键词匹配失效),可显著降低被误拦概率。注意:脱敏只针对客户端固定模板,不能也不应绕过对用户真实有害输入的审核。 - -### ⚠️ 免责声明 - -本项目为个人学习与研究用途,非官方产品,与腾讯 / CodeBuddy / OpenAI 无任何关联。使用本工具即表示你已阅读并同意:仅在你拥有合法订阅的前提下使用,遵守相关服务条款,自负风险。 - -### 📄 开源协议 - -[MIT](./LICENSE) - ---- - - -# English - -A minimal local **protocol converter / proxy** that exposes your already-logged-in **CodeBuddy / WorkBuddy (Tencent coding assistant)** subscription as a standard **OpenAI-compatible API**, so you can use it from any OpenAI-protocol client (ZCode, Cherry Studio, NextChat, LobeChat, Open WebUI, etc.). **No auth changes, no edits to your client config, cross-platform, single file.** +What changes in this mode: -> ⚠️ **Codex CLI note:** newer Codex CLI dropped `wire_api = "chat"` and only supports the `completions` format, so **this tool cannot be used with Codex CLI**. Use any OpenAI-compatible client instead. - -### ✨ Features - -- 🔄 **OpenAI-compatible**: standard `/v1/chat/completions` (streaming SSE), `/v1/models`, `/health`. -- 🪶 **Single-file & minimal**: core is one `converter.py`. -- 🔐 **Zero-auth hassle**: calls your locally-logged-in `codebuddy` CLI; reuses the desktop login session. -- 🖥️ **Cross-platform**: auto-locates CLI & auth on macOS / Windows / Linux. -- 🛡️ **Safe**: listens on `127.0.0.1` only; disables all built-in CLI tools for pure chat. +- No desktop app or login needed; the key is sent as a plain `Authorization: Bearer` header to `https://www.codebuddy.ai/v2/chat/completions`. Works headless (servers, containers, NAS). +- `/v1/models` lists the international catalog, verified live: `auto`, `hy3`, `glm-5.3/5.2/5.1/5v-turbo`, `minimax-m3`, `kimi-k3/k2.7/k2.6`, `deepseek-v4-pro/flash`, `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol`, `gemini-3.1-pro`. +- The startup preflight (which expects a desktop session) is skipped. -### 🚀 Quick Start +Backend quirks worth knowing (they produce confusing errors if you meet them blind): -```bash -git clone https://github.com/HanHan666666/codebuddy2openai.git -cd codebuddy2openai -pip install fastapi "uvicorn[standard]" -python3 converter.py -``` +- Requests must use `stream: true`; non-stream calls are rejected with error `11101`. The converter always streams upstream and aggregates when the client asked for non-streaming, so this only matters for raw calls. +- The first message must have role `system` (error `11128`). The converter prepends a system message when the client omits one. +- Model IDs are case-sensitive and reject unknown values with error `11102` (`Hy3` fails, `hy3` works). +- `gpt-5.6-luna` and friends reject very small `max_tokens` values (error `11133`, "integer_below_min_value"); stay above roughly 100. -Then point your OpenAI-compatible client at `http://127.0.0.1:8787/v1` (API base), leave the key blank unless you started the converter with `--api-key`. Note: Codex CLI is **not** supported (it dropped `wire_api = "chat"`); use ZCode, Cherry Studio, or any OpenAI-compatible client instead. +The key is a live credential: treat it like a password, and expect to rotate it when it expires. ### ⚠️ Disclaimer For personal learning and research only. Not affiliated with Tencent / CodeBuddy / OpenAI. Use only with a subscription you legally hold, in compliance with the relevant terms of service, at your own risk. -License: [MIT](./LICENSE) +Licence: [GPL-3.0-or-later](./LICENSE) --- diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..9070e49 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,232 @@ +# workbuddy2openai + +> 把 **CodeBuddy / WorkBuddy(腾讯代码助手)** 的订阅,转换成 **OpenAI 兼容 API**,让你能在任何支持 OpenAI 协议的客户端(ZCode、Cherry Studio、NextChat、LobeChat 等)里复用它。 + +> ⚠️ **关于 Codex CLI**:新版 Codex CLI 已不再支持 `wire_api = "chat"`,只支持 `completions` 格式,因此**本工具无法直接接入 Codex CLI**,请改用下方「OpenAI 兼容客户端」方案。 + +[English](README.md) · [中文文档](README.zh-CN.md) + +--- + +## 中文文档 + +一个极简的本地协议转换器(proxy / adapter):读取你本机已登录的 CodeBuddy 桌面端凭据,把它的对话能力包装成标准的 OpenAI `/v1/chat/completions`、`/v1/models` 接口。**不碰登录授权、不碰你已有的客户端配置、跨平台、单文件。** + +### ✨ 特性 + +- 🔄 **OpenAI 兼容**:标准 `/v1/chat/completions`(支持流式 SSE)、`/v1/models`、`/health`。 +- 🛠️ **Function Calling(工具调用)**:支持请求里的 `tools`,返回 OpenAI 格式的 `tool_calls`,可在 ZCode / Cherry Studio 等 agent 客户端里驱动工具、多轮回传结果。 +- 🪶 **单文件、极简**:核心就一个 `converter.py`,不复杂。 +- 🔐 **零授权改动**:直接调用本机已登录的 `codebuddy` CLI,自动复用桌面端登录态,不重新登录、不存密码。 +- 🖥️ **跨平台**:自动定位 macOS / Windows / Linux 上的 CLI 与登录文件。 +- 🛡️ **安全**:默认只监听 `127.0.0.1`;工具的声明与执行都由客户端负责,转换器只做鉴权与透传。 +- ⚡ **流式输出**:实时增量 token,体验与原生 OpenAI 流式一致。 + +### 🧠 它是怎么工作的 + +``` +ZCode / Cherry Studio / 任意 OpenAI 客户端 + │ POST /v1/chat/completions (标准 OpenAI 协议,含 tools) + ▼ +┌────────────────────┐ +│ converter.py │ ← 本地 FastAPI 服务 (127.0.0.1:8787) +│ 读 token + 注入 │ +│ 鉴权 header + 透传│ +└────────────────────┘ + │ POST /v2/chat/completions (带 Authorization/X-User-Id 等头) + ▼ +┌────────────────────────────────┐ +│ copilot.tencent.com 后端 │ ← 原生标准 OpenAI 协议 +│ (GLM-5.2 / Kimi / DeepSeek) │ 含原生 tools / tool_calls / SSE 流式 +└────────────────────────────────┘ +``` + +转换器直连 CodeBuddy 后端(`copilot.tencent.com/v2/chat/completions`),该后端本身就是**标准 OpenAI chat/completions 协议**。转换器只做两件事:①读取本机登录凭据并注入鉴权 header;②在本地 `/v1/*` 与后端 `/v2/*` 之间透传。因为后端原生支持 `tools` / `tool_calls`,function calling 是模型自带能力,**无需任何 prompt 注入或文本解析**。token 过期时转换器会自动调刷新接口并回写。 + +> 历史版本曾通过「调 CLI + `` 文本标签解析」实现 function calling,但在嵌套 agent(subagent)场景下,subagent 的输出会夹带标签污染对话。**v2.0 改为直连后端,彻底解决了这个问题。** + +### 📦 前置条件 + +1. 已安装并**登录** CodeBuddy / WorkBuddy 桌面端([腾讯云 CodeBuddy 官网](https://www.codebuddy.ai/))。转换器会自动在这些位置找登录态: + - **macOS**:`~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/*.info` + - **Windows**:`%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\*.info` + - **Linux**:`~/.local/share/CodeBuddyExtension\Data\Public\auth\*.info` +2. **Python 3.8+**(无需 Node.js,不再依赖 CLI)。 +3. 安装依赖(一次性): + ```bash + pip install -r requirements.txt + ``` + +### 🚀 快速开始 + +```bash +# 1. 克隆 +git clone https://github.com/HanHan666666/codebuddy2openai.git +cd codebuddy2openai + +# 2. 装依赖 +pip install -r requirements.txt + +# 3. 启动(确保 CodeBuddy 桌面端已登录) +python3 converter.py +# 看到「✅ 监听 http://127.0.0.1:8787」即成功 +``` + +启动时会做一次预检,打印账号信息和 token 状态。 + +### 🔑 API 密钥模式(`--direct-key`) + +WorkBuddy 国际版账号(`www.workbuddy.ai` 的 Keycloak 域)走桌面端令牌会 401:令牌格式与转换器调用的后端不匹配,刷新也会报 `invalid_grant`。如果你的账号是这种情况,可以完全跳过桌面端登录,改用 **CK_\* API 密钥**(在 [codebuddy.ai/profile/keys](https://www.codebuddy.ai/profile/keys) 生成,即 CLI 文档里的 `CODEBUDDY_API_KEY`): + +```bash +python3 converter.py --direct-key ck_你的密钥 +# 或:export CODEBUDDY_DIRECT_KEY=ck_你的密钥 +``` + +该模式下: + +- 无需桌面端登录;密钥以 `Authorization: Bearer` 直接发往 `https://www.codebuddy.ai/v2/chat/completions`。可无界面部署(服务器、容器、NAS)。 +- `/v1/models` 返回国际版目录(见下文「可用模型」)。 +- 启动预检(面向桌面端会话)自动跳过。 + +后端的几个坑(先知道,免得对着报错发懵): + +- 请求必须 `stream: true`,非流式会被拒(错误码 `11101`)。转换器对上游永远走流式、再按客户端要求聚合,所以只有自己裸调接口才会碰到。 +- 第一条消息必须是 `system` 角色(错误码 `11128`)。转换器会在客户端没给时自动补一条。 +- 模型 ID **区分大小写**,未知的直接拒(错误码 `11102`):`hy3` 可以,`Hy3` 不行。 +- `gpt-5.6-luna` 等模型对过小的 `max_tokens` 会拒绝(错误码 `11133`,integer_below_min_value),建议 ≥100。 + +密钥是有效凭证,按密码对待,到期记得轮换。 + +### 🛠️ Function Calling(工具调用) + +后端原生支持标准 OpenAI function calling。客户端(如 ZCode / Cherry Studio)在请求里带 `tools`,模型原生返回 `tool_calls`(`finish_reason:"tool_calls"`),客户端执行工具后把 `role:"tool"` 的结果回传即可——和直连 OpenAI 完全一致。流式、非流式、多轮工具调用都支持。 + +### 🔌 接入客户端 + +⚠️ **关于 Codex CLI(重要)**:新版本 Codex CLI 已**移除** `wire_api = "chat"` 的支持,目前只认 `completions` 格式,因此**本转换器无法直接接入 Codex CLI**。仓库里的 `codex-codebuddy.example.toml` 仅作历史/参考保留,实测在当前 Codex 上跑不通,请不要照抄。 + +✅ **可用方式 —— 任何标准 OpenAI 兼容客户端**(走 `/v1/chat/completions`)。常见选择: + +- **ZCode**(OpenAI 兼容 Agent) +- **Cherry Studio** +- **NextChat / LobeChat / Open WebUI** +- 任何支持自定义 `base_url` 的 OpenAI SDK 客户端 + +通用接入步骤(以这类客户端为例): + +1. 保持转换器运行:`python3 converter.py` +2. 在客户端的「自定义模型 / OpenAI 兼容」设置里: + - **API Base / 接口地址**:`http://127.0.0.1:8787/v1` + - **API Key**:留空(转换器默认不校验);若启动时用了 `--api-key`,则填同一个 + - **模型名**:`glm-5.2`(或 `kimi-k2.7` / `deepseek-v4-pro` / `auto` 等,见下方列表) + +示例配置(如果你用的客户端读 toml / 自定义 provider 片段): + +```toml +[model_providers.codebuddy] +name = "CodeBuddy (via local converter)" +base_url = "http://127.0.0.1:8787/v1" +env_key = "CODEBUDDY2OPENAI_KEY" +# 注意:本接口是 OpenAI chat 协议(/v1/chat/completions)。 +# Codex CLI 因不再支持该 wire_api 而无法使用,请用 ZCode / Cherry Studio 等 OpenAI 兼容客户端。 +``` + +### 🧪 curl 验证 + +```bash +# 列模型 +curl http://127.0.0.1:8787/v1/models + +# 非流式 +curl http://127.0.0.1:8787/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"glm-5.2","messages":[{"role":"user","content":"你好"}]}' + +# 流式 +curl -N http://127.0.0.1:8787/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"数1到5"}]}' +``` + +### 🤖 可用模型 + +`glm-5.2`、`glm-5.1`、`glm-5v-turbo`、`kimi-k2.7`、`kimi-k2.6`、`kimi-k2.5`、`deepseek-v4-pro`、`deepseek-v4-flash`、`minimax-m3-pay`、`hy3-preview-agent`、`auto` + +(来自 CLI `--help` 的 `--model` 说明,具体可用性以你的订阅为准。) + +**API 密钥模式(国际版)**下,`/v1/models` 返回的是国际版目录(2026-08-31 实测全部可用): + +`auto`、`hy3`、`glm-5.3`、`glm-5.2`、`glm-5.1`、`glm-5v-turbo`、`minimax-m3`、`kimi-k3`、`kimi-k2.7`、`kimi-k2.6`、`deepseek-v4-pro`、`deepseek-v4-flash`、`gpt-5.6-luna`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gemini-3.1-pro` + +注意:模型 ID **区分大小写**(`hy3` 可以,`Hy3` 不行);`gpt-5.6-luna` 等模型对过小的 `max_tokens` 会拒绝(建议 ≥100)。 + +### 📁 项目结构 + +``` +codebuddy2openai/ +├── converter.py # 转换器主程序(单文件) +├── desensitize.py # 脱敏模块(可选,--desensitize 启用) +├── codex-codebuddy.example.toml # provider 配置示例片段(仅供参考;Codex CLI 已不支持,见上方说明) +├── README.md +└── LICENSE +``` + +### 🔧 命令行参数 + +| 参数 | 说明 | +|------|------| +| `--direct-key ` | **API 密钥模式**:跳过桌面端登录,直接用 CK_\* 密钥调用国际版后端。适合 WorkBuddy 国际版账号(桌面端令牌会 401)以及无界面部署。详见下文「API 密钥模式」。 | + +``` +python3 converter.py [--host HOST] [--port PORT] [--api-key KEY] [--direct-key CK_KEY] [--log PATH] [--desensitize] [--skip-check] +``` + +| 参数 | 默认 | 说明 | +|------|------|------| +| `--host` | `127.0.0.1` | 监听地址 | +| `--port` | `8787` | 监听端口 | +| `--api-key` | 无 | 启用鉴权;客户端需带同样 key(也可用环境变量 `CODEBUDDY2OPENAI_KEY`)| +| `--direct-key` | 无 | CK_\* 密钥(或环境变量 `CODEBUDDY_DIRECT_KEY`);设置后进入 API 密钥模式,详见下文 | +| `--log` | 无 | **开启日志并写到该文件**(如 `--log converter.log`)。不传则不记。也可用环境变量 `CODEBUDDY2OPENAI_LOG`。| +| `--desensitize` | 关 | 启用脱敏:对 system 消息里的合规声明敏感词(DoS/exploit/credential/C2 等)插入零宽空格,缓解被后端内容审核误拦(见下方 FAQ)。| +| `--skip-check` | 否 | 跳过启动预检 | + +示例: +```bash +python3 converter.py --log converter.log # 记日志到当前目录 converter.log +python3 converter.py --log /tmp/cb.log # 记到指定路径 +python3 converter.py # 不记日志 +``` + +每条日志记录:模型、是否流式、消息数、最后一条用户提问、耗时、finish_reason、工具调用、token 数;若后端内容审核拦截会标 `⚠️内容审核拦截`。**每次请求都用唯一 ID 串起来,并完整落盘**:发往后端的完整请求体(REQUEST BODY)、后端返回的完整内容(非流式是聚合后的 RESPONSE BODY,流式是后端原始的 RESPONSE RAW SSE)。排查"内容审核拦截""返回异常"等问题时,直接看日志里对应 ID 的完整报文即可。示例: +``` +[2026-06-19 11:56:32] [9cc4488e] ▶ REQUEST glm-5.2 | stream=False | msgs=1 | last_user='Reply: pong' +[2026-06-19 11:56:32] [9cc4488e] ── REQUEST BODY ── +{ "model": "glm-5.2", "messages": [{"role":"user","content":"Reply: pong"}] } +[2026-06-19 11:56:35] [9cc4488e] ◀ RESPONSE glm-5.2 | 3.0s | finish=stop | tokens=11 +[2026-06-19 11:56:35] [9cc4488e] ── RESPONSE BODY ── +{ "choices":[{"message":{"content":"pong"},...}], "usage":{...} } +``` + +### ❓ 常见问题 + +- **找不到登录文件**:在桌面端完成登录(不是只装、要登进去)。路径见上方「前置条件」。**国际版(workbuddy.ai)账号例外**:桌面端令牌与后端不匹配,登录后仍会 401,请改用「API 密钥模式」(`--direct-key`,见下文)。 +- **客户端报 401**:转换器若用了 `--api-key`,客户端那边要带同样的 key;若是后端 401,可能是 token 失效(转换器会自动刷新,若仍失败需在桌面端重新登录)。 +- **响应慢**:可换 `deepseek-v4-flash` 等更快的模型。 +- **"敏感内容"被拦截**:这是 CodeBuddy 后端的**内容审核**(腾讯合规策略),在模型推理之前就拦了。常见触发原因是客户端注入的 system prompt 里含安全相关英文术语(如 DoS / exploit / credential / C2 等——这些往往是客户端**合规声明模板**里的"拒绝作恶"措辞,属误伤)。两种应对:①用 `--log xxx.log` 在日志里看 `⚠️内容审核拦截` 标记定位是哪条请求;②加 `--desensitize` 启用脱敏模块(`desensitize.py`),它对 system 消息里的这类合规词插入零宽空格(人/模型读无差别,但后端关键词匹配失效),可显著降低被误拦概率。注意:脱敏只针对客户端固定模板,不能也不应绕过对用户真实有害输入的审核。 + +### ⚠️ 免责声明 + +本项目为个人学习与研究用途,非官方产品,与腾讯 / CodeBuddy / OpenAI 无任何关联。使用本工具即表示你已阅读并同意:仅在你拥有合法订阅的前提下使用,遵守相关服务条款,自负风险。 + +### 📄 开源协议 + +[GPL-3.0-or-later](./LICENSE) + +--- + + + +**关键词 / Keywords:** codebuddy to openai · codebuddy2openai · codebuddy openai compatible api · codebuddy api proxy · codebuddy workbuddy openai adapter · tencent codebuddy openai · codebuddy glm-5.2 api · codebuddy kimi deepseek openai · openai compatible proxy local llm gateway · codebuddy function calling · codebuddy tool use tool_calls · codebuddy zcode cherry studio · 腾讯代码助手 openai · codebuddy 转 openai · codebuddy 接入 zcode cherry studio · 本地大模型代理 openai 协议 · codebuddy 订阅 复用 · workbuddy api 转换 · codebuddy 工具调用 + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6deaf60 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,46 @@ +# Security policy + +## What this project handles + +This is a local proxy. It reads the credentials that the CodeBuddy / WorkBuddy desktop client already stored on your machine, then re-exposes that account as an OpenAI-compatible HTTP API. Two consequences shape this policy: + +- The proxy holds a live session credential for a paid account. Anyone who can reach the listening port can spend that account's quota. +- The default bind address is `127.0.0.1`. Changing it to a routable address exposes the account to your network, and this project has no user database, no roles, and no per-client rate limit. + +## Supported versions + +Only the latest commit on the default branch is supported. This is a small project without long-lived release branches, so fixes land on the default branch and nowhere else. + +## Reporting a vulnerability + +Email **infoleonid@protonmail.com**. Do not open a public issue for a security problem. + +Include what you have: the version or commit you tested, the platform, a description of the impact, and the smallest reproduction you can manage. A proof of concept helps but is not required to file. + +You can expect an acknowledgement within a few days. This is a hobby project maintained by one person, so please allow reasonable time before chasing a reply. + +## In scope + +- Credential leakage: the session token or API key reaching process listings, log files, error responses, or crash output. +- Authentication bypass on the local API, including any route that answers before the key check. +- Path traversal or arbitrary file read through the credential-loading code. +- Request smuggling or header injection in the translation between the OpenAI request shape and the upstream one. +- Denial of service that the default configuration does not already accept as a local-only tradeoff. + +## Out of scope + +- Anything that requires the attacker to already read files as your user. Local filesystem access to the credential store is the threat model's starting point, not a finding. +- Exposure caused by rebinding to a routable address, or by putting the proxy behind a reverse proxy without authentication. The README warns about the default bind; changing it moves the risk onto your own network. +- Account suspension or quota exhaustion caused by your own use of the upstream service. +- Vulnerabilities in the upstream desktop client or the upstream API, reported to the wrong project. +- Missing rate limits, request size caps, or TLS on the local listener. A loopback-only listener serves one user. + +## Hardening checklist + +If you run this beyond your own machine: + +1. Keep the bind address on `127.0.0.1` unless you have a specific reason. +2. Set a strong `--api-key` value. An empty key disables the check, and the proxy logs a warning when it starts that way. +3. Keep the key out of the command line, where process listings expose it. Read it from the environment or from `.env` instead. +4. Keep `.env` out of version control. The repository's ignore rules already cover it; check before you commit. +5. Terminate TLS and add authentication in front of the proxy if you reach it over a network. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..9a77419 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,74 @@ +# Third-party notices + +This project depends on the packages below. They are not vendored: install the project's dependency file and your package manager resolves each one under its own licence. + +## Runtime dependencies + +### FastAPI + +- Licence: MIT +- Use: HTTP routing, request validation, and the ASGI application. +- Source: https://github.com/fastapi/fastapi + +### Uvicorn + +- Licence: BSD 3-Clause +- Use: ASGI server that runs the application. +- Source: https://github.com/encode/uvicorn + +The `uvicorn[standard]` extra pulls in further packages, including `httptools` (MIT), `uvloop` (MIT, not installed on Windows), `watchfiles` (MIT), `websockets` (BSD 3-Clause), `python-dotenv` (BSD 3-Clause), `PyYAML` (MIT), and `colorama` (BSD 3-Clause, Windows only). Each carries its own licence, and the `uvicorn[standard]` dependency list is the authoritative source. + +### HTTPX + +- Licence: BSD 3-Clause +- Use: HTTP client for requests to the upstream service. +- Source: https://github.com/encode/httpx + +HTTPX depends on `httpcore` (BSD 3-Clause), `h11` (MIT), `certifi` (MPL 2.0), and `idna` (BSD 3-Clause). + +## Development dependencies + +### pytest + +- Licence: MIT +- Use: test runner. +- Source: https://github.com/pytest-dev/pytest + +pytest depends on `iniconfig` (MIT), `packaging` (Apache 2.0 or BSD 2-Clause), and `pluggy` (MIT). + +### Starlette TestClient + +- Licence: BSD 3-Clause +- Use: exercised through FastAPI, which re-exports it. It requires HTTPX. +- Source: https://github.com/encode/starlette + +## Standard library + +Several modules from the Python standard library are used, including `argparse`, `json`, `os`, `pathlib`, `re`, `sys`, `threading`, and `time`. They ship with Python under the Python Software Foundation Licence, version 2. + +## Documents + +### Contributor Covenant 3.0 + +`CODE_OF_CONDUCT.md` is adapted from the Contributor Covenant, version 3.0, available at https://www.contributor-covenant.org/version/3/0/ + +The Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0). The document's own attribution section reproduces this notice. + +## Upstream project + +This repository is a fork of https://github.com/HanHan666666/codebuddy2openai, which is licensed under the MIT Licence, copyright (c) 2026 HanHan666666. + +The original MIT terms are preserved in [LICENSE](LICENSE) alongside this fork's own licence. See that file for the split. + +## Keeping this list current + +This list covers the declared dependencies. It does not enumerate every transitive package with the exact resolved version, because those versions depend on when you install. + +To generate a complete inventory including transitive dependencies and their resolved versions, install the development requirements and run a licence report, for example: + +``` +.venv/bin/python -m pip install pip-licenses +.venv/bin/python -m pip-licenses --format=markdown --with-urls --with-license-file --with-description +``` + +Regenerate that report when a dependency is added, removed, or upgraded. diff --git a/converter.py b/converter.py index 9d853fa..a1e8197 100644 --- a/converter.py +++ b/converter.py @@ -24,6 +24,7 @@ import argparse import json import os +import re import sys import threading import time @@ -49,6 +50,15 @@ def desensitize_body(body, roles=("system",)): DEFAULT_DOMAIN = "www.codebuddy.cn" USER_AGENT = "codebuddy2openai/2.0" + +def _env_first(*names: str) -> str: + for name in names: + value = os.environ.get(name) + if value: + return value + return "" + + # --------------------------------------------------------------------------- # 平台相关:定位 auth 目录 # --------------------------------------------------------------------------- @@ -186,12 +196,31 @@ def summary(self) -> dict: # 模型列表 # --------------------------------------------------------------------------- -DEFAULT_MODELS = [ +BACKEND_BY_DOMAIN = { + "www.workbuddy.ai": "https://www.workbuddy.ai", + "www.codebuddy.cn": "https://copilot.tencent.com", +} + +def backend_for_domain(domain: str | None) -> str: + return BACKEND_BY_DOMAIN.get(domain or "", "https://copilot.tencent.com") + +# Model catalog for the China desktop-token path (unchanged from upstream). +CN_MODELS = [ "glm-5.2", "glm-5.1", "glm-5v-turbo", "kimi-k2.7", "kimi-k2.6", "kimi-k2.5", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3-pay", "hy3-preview-agent", "auto", ] +# Verified live (2026-08-31) against the international endpoint with a CK_* key. +# Upstream rejects unknown ids with code 11102, so each entry below answered 200. +INTL_MODELS = [ + "auto", "hy3", "hy4-preview", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5v-turbo", + "minimax-m3", "kimi-k3", "kimi-k2.7", "kimi-k2.6", + "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4.1-flash", + "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gemini-3.1-pro", +] + +DEFAULT_MODELS = CN_MODELS # 后端请求体里出现过的额外字段(透传时若客户端给了就保留) PASSTHROUGH_BODY_KEYS = { @@ -208,7 +237,9 @@ def summary(self) -> dict: app = FastAPI(title="codebuddy2openai", version="2.0") CONFIG: dict = {"api_key": "", "cred": None, "log_path": None, - "desensitize": False} # cred: CredentialManager | None + "desensitize": False, # cred: CredentialManager | None + "direct_key": None} # CK_* API-key mode: bypass desktop token entirely +DIRECT_KEY_BACKEND = "https://www.codebuddy.ai" # --------------------------------------------------------------------------- @@ -253,6 +284,8 @@ def _check_auth(authorization: Optional[str], x_api_key: Optional[str]): def _cred() -> CredentialManager: + if CONFIG.get("direct_key"): + raise HTTPException(status_code=500, detail={"error": {"message": "internal: _cred called in direct-key mode", "type": "auth_error"}}) if CONFIG["cred"] is None: raise HTTPException(status_code=503, detail={"error": {"message": "未找到登录凭据,请先在桌面端登录 CodeBuddy/WorkBuddy", "type": "auth_error"}}) return CONFIG["cred"] @@ -275,8 +308,9 @@ def health(): def list_models(authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): _check_auth(authorization, x_api_key) + models = INTL_MODELS if CONFIG.get("direct_key") else DEFAULT_MODELS data = [{"id": m, "object": "model", "created": 1700000000, "owned_by": "codebuddy"} - for m in DEFAULT_MODELS] + for m in models] return {"object": "list", "data": data} @@ -285,7 +319,9 @@ async def chat_completions(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): _check_auth(authorization, x_api_key) - cred = _cred() + cred = None + if not CONFIG.get("direct_key"): + cred = _cred() try: payload = await request.json() @@ -322,8 +358,24 @@ async def chat_completions(request: Request, # 完整请求体(发往后端的实际内容;若启用脱敏,这里已是脱敏后) _log(f"[{rid}] ── REQUEST BODY (发往后端) ──\n{json.dumps(body, ensure_ascii=False, indent=2)}") - headers = cred.get_headers() - url = f"{BACKEND}/v2/chat/completions" + if CONFIG.get("direct_key"): + # CK_* API-key mode: no desktop credential, plain Bearer against + # the international endpoint. Upstream rejects non-stream and + # system-less requests (errors 11101/11128); both are handled below. + headers = {"Content-Type": "application/json", "Accept": "application/json", + "Authorization": f"Bearer {CONFIG['direct_key']}", + "User-Agent": USER_AGENT} + backend = DIRECT_KEY_BACKEND + else: + headers = cred.get_headers() + # Backend depends on which realm the credentials belong to + # (WorkBuddy international -> www.workbuddy.ai, CodeBuddy CN -> copilot.tencent.com) + domain = (cred._session().get("auth") or {}).get("domain") + backend = backend_for_domain(domain) + # Upstream requires the first message to be a system prompt + if messages and messages[0].get("role") != "system": + body["messages"] = [{"role": "system", "content": "You are a helpful assistant."}] + body.get("messages", []) + url = f"{backend}/v2/chat/completions" t0 = time.time() if client_wants_stream: @@ -335,14 +387,28 @@ async def chat_completions(request: Request, # 非流式:后端只支持流式,这里把后端 SSE 聚合成单个 chat.completion 响应 try: - async with httpx.AsyncClient(timeout=300) as c: - async with c.stream("POST", url, headers=headers, json=body) as r: - if r.status_code != 200: - raw = await r.aread() - _log(f"[{rid}] ✗ HTTP {r.status_code} | {model_name} | {_truncate(raw.decode('utf-8','replace'),200)}") - _log(f"[{rid}] ── ERROR BODY ──\n{raw.decode('utf-8','replace')}") - raise HTTPException(status_code=r.status_code, detail=_safe_err_raw(raw, r.status_code)) - collected = await _collect_stream(r) + collected = await _post_collect(url, headers, body, model_name, rid) + except _UpstreamGateError as gate: + # Key-path quota/channel gate: one retry over the desktop-token path, + # when this machine has WorkBuddy credentials. Path-specific failure, + # so the same body can legitimately succeed over the other route. + if not _token_fallback_ready(): + _log(f"[{rid}] ✗ {gate.code} and no desktop token path - surfacing") + raise gate.http_exc + fb_headers, fb_backend = _token_fallback_route() + fb_url = f"{fb_backend}/v2/chat/completions" + _log(f"[{rid}] ↻ {gate.code} on key path - retrying over desktop token ({fb_backend})") + try: + collected = await _post_collect(fb_url, fb_headers, body, model_name, rid) + _log(f"[{rid}] ✓ desktop-token retry succeeded") + except _UpstreamGateError as gate2: + _log(f"[{rid}] ✗ desktop-token retry also gated ({gate2.code}) - surfacing original") + raise gate.http_exc + except HTTPException: + raise + except httpx.HTTPError as e: + _log(f"[{rid}] ✗ desktop-token retry network error: {e}") + raise gate.http_exc except HTTPException: raise except httpx.HTTPError as e: @@ -460,6 +526,89 @@ def _safe_err_raw(raw: bytes, status: int) -> dict: return {"error": {"message": raw.decode("utf-8", "replace")[:500], "type": "upstream_error", "code": status}} +# Upstream error codes worth one retry over the desktop-token path when the +# CK_* key path fails: 6004 = usage/frequency limit on the key's quota, +# 11128 = invocation gated by channel (the desktop token presents as the +# approved channel). Both are path-specific, not model-specific. +TOKEN_PATH_RETRY_CODES = {6004, 11128} + + +def _err_code(detail: dict) -> int | None: + """Extract the numeric upstream code from an error detail payload.""" + if not isinstance(detail, dict): + return None + err = detail.get("error") if isinstance(detail.get("error"), dict) else detail + for key in ("code", "msg_code"): + val = err.get(key) + if isinstance(val, int): + return val + if isinstance(val, str) and val.isdigit(): + return int(val) + msg = str(err.get("msg") or err.get("message") or "") + m = re.search(r'\bcode["\s:]+(\d{4,5})\b', msg) + if m: + return int(m.group(1)) + return None + + +def _token_fallback_ready() -> bool: + """True when desktop credentials exist and can be tried as a second path.""" + cred = CONFIG.get("cred") + if cred is None: + return False + try: + cred.get_headers() + return True + except Exception as e: + _log(f"[fallback] desktop token path unavailable: {e}") + return False + + +def _token_fallback_route() -> tuple[dict, str]: + """Headers and backend for the desktop-token path.""" + cred = _cred() + headers = {"Content-Type": "application/json", "Accept": "application/json", + "User-Agent": USER_AGENT} + headers.update(cred.get_headers()) + domain = (cred._session().get("auth") or {}).get("domain") + return headers, backend_for_domain(domain) + + +class _UpstreamGateError(Exception): + """Upstream refused the request for path-specific reasons (6004/11128). + + Carries the upstream code and the HTTPException to surface when no + fallback route succeeds. + """ + + def __init__(self, code: int, http_exc: HTTPException): + super().__init__(f"upstream gate {code}") + self.code = code + self.http_exc = http_exc + + +async def _post_collect(url: str, headers: dict, body: dict, + model_name: str, rid: str) -> dict: + """POST and aggregate the upstream SSE stream into one chat.completion. + + Raises _UpstreamGateError when upstream answers a path-specific gate + code from TOKEN_PATH_RETRY_CODES; other non-200s raise HTTPException + as before. + """ + async with httpx.AsyncClient(timeout=300) as c: + async with c.stream("POST", url, headers=headers, json=body) as r: + if r.status_code != 200: + raw = await r.aread() + _log(f"[{rid}] ✗ HTTP {r.status_code} | {model_name} | {_truncate(raw.decode('utf-8','replace'),200)}") + _log(f"[{rid}] ── ERROR BODY ──\n{raw.decode('utf-8','replace')}") + detail = _safe_err_raw(raw, r.status_code) + code = _err_code(detail) + if code in TOKEN_PATH_RETRY_CODES: + raise _UpstreamGateError(code, HTTPException(status_code=r.status_code, detail=detail)) + raise HTTPException(status_code=r.status_code, detail=detail) + return await _collect_stream(r) + + async def _stream_upstream(url: str, headers: dict, body: dict, model_name: str = "?", t0: float = 0.0, rid: str = ""): """把后端 SSE 原样转发给客户端(后端已是标准 OpenAI SSE,含 tool_calls)。 @@ -586,7 +735,7 @@ def main(): ap = argparse.ArgumentParser(description="CodeBuddy -> OpenAI 兼容转换器(直连后端)") ap.add_argument("--host", default="127.0.0.1") ap.add_argument("--port", type=int, default=8787) - ap.add_argument("--api-key", default=os.environ.get("CODEBUDDY2OPENAI_KEY", ""), + ap.add_argument("--api-key", default=_env_first("WORKBUDDY2OPENAI_KEY", "CODEBUDDY2OPENAI_KEY"), help="可选:要求客户端携带的 API key(默认不校验)") ap.add_argument("--log", default=None, metavar="PATH", help="开启日志并写到该文件(如 --log converter.log 或 --log /tmp/cb.log)。" @@ -594,17 +743,20 @@ def main(): ap.add_argument("--desensitize", action="store_true", help="启用脱敏:对 system 消息里的合规模板敏感词(DoS/exploit/credential 等)" "插入零宽空格,缓解被后端内容审核误拦。默认关闭。") + ap.add_argument("--direct-key", default=_env_first("WORKBUDDY_DIRECT_KEY", "CODEBUDDY_DIRECT_KEY"), + help="CK_* CodeBuddy API key: bypass desktop session, call the international backend directly") ap.add_argument("--skip-check", action="store_true", help="跳过启动预检") args = ap.parse_args() CONFIG["api_key"] = args.api_key CONFIG["desensitize"] = args.desensitize + CONFIG["direct_key"] = (args.direct_key or "").strip() or None # --log 直接指定文件路径即开启;不传则不记 - CONFIG["log_path"] = args.log if args.log else os.environ.get("CODEBUDDY2OPENAI_LOG") + CONFIG["log_path"] = args.log if args.log else (_env_first("WORKBUDDY2OPENAI_LOG", "CODEBUDDY2OPENAI_LOG") or None) af = find_auth_file() - CONFIG["cred"] = CredentialManager(af) if af else None + CONFIG["cred"] = CredentialManager(af) if (af and not CONFIG["direct_key"]) else None - if not args.skip_check: + if not args.skip_check and not CONFIG["direct_key"]: preflight() sys.stderr.write(f"\n✅ 监听 http://{args.host}:{args.port}(直连后端,原生 function calling)\n") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..648ef0c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[project] +name = "workbuddy2openai" +version = "2.0.0" +description = "Local OpenAI-compatible proxy for the CodeBuddy / WorkBuddy desktop client" +readme = "README.md" +requires-python = ">=3.11" +license = "GPL-3.0-or-later" +license-files = ["LICENSE"] +authors = [{ name = "Leonid Dalin" }] +keywords = [ + "codebuddy", + "workbuddy", + "openai", + "proxy", + "adapter", + "llm", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: Proxy Servers", +] +dependencies = [ + "fastapi>=0.110", + "httpx>=0.27", + "uvicorn[standard]>=0.29", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[project.scripts] +workbuddy2openai = "converter:main" + +[project.urls] +Repository = "https://github.com/leonid-dalin/codebuddy2openai" +Upstream = "https://github.com/HanHan666666/codebuddy2openai" +Issues = "https://github.com/leonid-dalin/codebuddy2openai/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +only-include = ["converter.py", "desensitize.py"] +sources = ["."] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..eeb9d41 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -q diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a2b8460 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.110 +uvicorn[standard]>=0.29 +httpx>=0.27 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5a03b25 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,81 @@ +"""Shared fixtures. Import converter.py once per session and isolate CONFIG between tests.""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +@pytest.fixture(scope="session") +def converter_module(): + """Import converter.py by path, once per session, without running main().""" + spec = importlib.util.spec_from_file_location( + "converter_under_test", REPO_ROOT / "converter.py" + ) + module = importlib.util.module_from_spec(spec) + sys.modules["converter_under_test"] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture() +def fresh_config(converter_module): + """Reset CONFIG to defaults so tests cannot leak state into each other.""" + converter_module.CONFIG["api_key"] = "" + converter_module.CONFIG["desensitize"] = False + converter_module.CONFIG["log_path"] = None + converter_module.CONFIG["direct_key"] = None + converter_module.CONFIG["cred"] = None + yield converter_module.CONFIG + converter_module.CONFIG["api_key"] = "" + converter_module.CONFIG["desensitize"] = False + converter_module.CONFIG["log_path"] = None + converter_module.CONFIG["direct_key"] = None + converter_module.CONFIG["cred"] = None + + +@pytest.fixture() +def client(converter_module, fresh_config): + """FastAPI TestClient wired to a converter with no credentials.""" + from fastapi.testclient import TestClient + + return TestClient(converter_module.app) + + +@pytest.fixture() +def authed_client(converter_module, client): + converter_module.CONFIG["api_key"] = "test-key" + client.headers.update({"Authorization": "Bearer test-key"}) + return client + + +@pytest.fixture() +def direct_key_client(authed_client, converter_module): + converter_module.CONFIG["direct_key"] = "ck_test_dummy" + return authed_client + + +@pytest.fixture() +def fake_auth_file(tmp_path): + """A plausible desktop auth file for the WorkBuddy international realm.""" + auth_dir = tmp_path / "CodeBuddyExtension" / "Data" / "Public" / "auth" + auth_dir.mkdir(parents=True) + payload = { + "account": { + "uid": "test-uid-1234", + "nickname": "tester@example.com", + "enterpriseId": None, + }, + "auth": { + "accessToken": "test-access-token", + "refreshToken": "test-refresh-token", + "expiresAt": 9999999999999, + "domain": "www.workbuddy.ai", + }, + } + auth_file = auth_dir / "workbuddy-desktop-ai.info" + auth_file.write_text(__import__("json").dumps(payload), encoding="utf-8") + return auth_file, payload diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py new file mode 100644 index 0000000..2603867 --- /dev/null +++ b/tests/test_api_endpoints.py @@ -0,0 +1,297 @@ +"""Endpoint tests through the FastAPI test client, upstream mocked. + +The tests patch httpx.AsyncClient.stream, the seam the converter uses for +both the streaming and the aggregating path. +""" + +import json + +import httpx +import pytest + + +def sse_chunk(content: str, model: str = "glm-5.2") -> bytes: + payload = { + "id": "cmb-test", "model": model, "object": "chat.completion.chunk", + "created": 1700000000, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": content}, + "finish_reason": ""}], + "usage": None, + } + return f"data: {json.dumps(payload)}\n\n".encode() + + +def sse_finish(model: str = "glm-5.2") -> bytes: + payload = { + "id": "cmb-test", "model": model, "object": "chat.completion.chunk", + "created": 1700000000, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + return f"data: {json.dumps(payload)}\n\ndata: [DONE]\n\n".encode() + + +class FakeUpstream: + """Stands in for the Tencent backend. Records the request, replays chunks.""" + + def __init__(self, chunks: list[bytes], status_code: int = 200): + self.chunks = chunks + self.status_code = status_code + self.requests: list[dict] = [] + + def __call__(self, monkeypatch, converter_module): + upstream = self + + class FakeResponse: + def __init__(self): + self.status_code = upstream.status_code + + async def aiter_bytes(self): + for chunk in upstream.chunks: + yield chunk + + async def aiter_lines(self): + buffer = b"".join(upstream.chunks).decode("utf-8") + for line in buffer.splitlines(): + yield line + + async def aread(self): + return b"".join(upstream.chunks) + + class FakeStreamCtx: + async def __aenter__(self): + return FakeResponse() + + async def __aexit__(self, *args): + return False + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + def stream(self, method, url, headers=None, json=None, **kwargs): + upstream.requests.append( + {"method": method, "url": url, "headers": headers or {}, "body": json or {}} + ) + return FakeStreamCtx() + + monkeypatch.setattr(converter_module.httpx, "AsyncClient", FakeAsyncClient) + return upstream + + @property + def last(self) -> dict: + return self.requests[-1] + + +@pytest.fixture() +def upstream_ok(monkeypatch, converter_module): + def install(chunks: list[bytes], status_code: int = 200) -> FakeUpstream: + return FakeUpstream(chunks, status_code)(monkeypatch, converter_module) + return install + + +class TestHealth: + def test_reports_ok_without_credentials(self, client): + response = client.get("/health") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + + def test_direct_key_mode_reported_in_health(self, direct_key_client, converter_module): + response = direct_key_client.get("/health") + assert response.status_code == 200 + # /health must not leak the key itself + assert "ck_test_dummy" not in response.text + + +class TestListModels: + def test_token_mode_serves_china_catalog(self, authed_client, converter_module): + response = authed_client.get("/v1/models") + assert response.status_code == 200 + ids = [m["id"] for m in response.json()["data"]] + assert ids == converter_module.CN_MODELS + assert "gpt-5.6-luna" not in ids + + def test_direct_key_mode_serves_international_catalog(self, direct_key_client, converter_module): + response = direct_key_client.get("/v1/models") + assert response.status_code == 200 + ids = [m["id"] for m in response.json()["data"]] + assert ids == converter_module.INTL_MODELS + assert "gpt-5.6-luna" in ids + assert "minimax-m3-pay" not in ids + + def test_requires_client_key_when_configured(self, converter_module, client): + converter_module.CONFIG["api_key"] = "secret" + assert client.get("/v1/models").status_code == 401 + + def test_open_when_no_client_key_configured(self, client): + assert client.get("/v1/models").status_code == 200 + + +class TestChatCompletionsAuth: + def test_rejects_unauthenticated_request_when_key_set(self, converter_module, client): + converter_module.CONFIG["api_key"] = "secret" + response = client.post("/v1/chat/completions", json={ + "model": "glm-5.2", + "messages": [{"role": "user", "content": "hi"}], + }) + assert response.status_code == 401 + + +class TestChatCompletionsDirectKeyMode: + def test_sends_bearer_key_to_international_backend( + self, direct_key_client, converter_module, upstream_ok + ): + upstream = upstream_ok([sse_chunk("OK"), sse_finish()]) + + response = direct_key_client.post("/v1/chat/completions", json={ + "model": "hy3", + "messages": [{"role": "user", "content": "say OK"}], + "max_tokens": 100, + }) + assert response.status_code == 200 + assert upstream.last["url"].startswith("https://www.codebuddy.ai/v2/chat/completions") + assert upstream.last["headers"]["Authorization"] == "Bearer ck_test_dummy" + assert "X-User-Id" not in upstream.last["headers"] + + def test_prepends_system_message_when_missing( + self, direct_key_client, converter_module, upstream_ok + ): + upstream = upstream_ok([sse_chunk("OK"), sse_finish()]) + + direct_key_client.post("/v1/chat/completions", json={ + "model": "glm-5.2", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 100, + }) + sent = upstream.last["body"]["messages"] + assert sent[0]["role"] == "system" + + def test_keeps_client_system_message_when_present( + self, direct_key_client, converter_module, upstream_ok + ): + upstream = upstream_ok([sse_chunk("OK"), sse_finish()]) + + direct_key_client.post("/v1/chat/completions", json={ + "model": "glm-5.2", + "messages": [ + {"role": "system", "content": "You are terse."}, + {"role": "user", "content": "hi"}, + ], + "max_tokens": 100, + }) + sent = upstream.last["body"]["messages"] + assert sent[0]["role"] == "system" + assert sent[0]["content"] == "You are terse." + assert len(sent) == 2 + + def test_forces_stream_true_upstream_for_non_stream_client( + self, direct_key_client, converter_module, upstream_ok + ): + upstream = upstream_ok([sse_chunk("OK"), sse_finish()]) + + response = direct_key_client.post("/v1/chat/completions", json={ + "model": "glm-5.2", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 100, + "stream": False, + }) + assert response.status_code == 200 + assert upstream.last["body"]["stream"] is True + # The client still receives a single non-streaming completion. + body = response.json() + assert body["object"] == "chat.completion" + assert body["choices"][0]["message"]["content"] == "OK" + + def test_streams_sse_to_streaming_client( + self, direct_key_client, converter_module, upstream_ok + ): + upstream_ok([sse_chunk("HE"), sse_chunk("LLO"), sse_finish()]) + + response = direct_key_client.post("/v1/chat/completions", json={ + "model": "glm-5.2", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 100, + "stream": True, + }) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + # Chunks pass through individually; "HE" and "LLO" never merge in flight. + assert '"content": "HE"' in response.text + assert '"content": "LLO"' in response.text + assert "data: [DONE]" in response.text + + def test_non_stream_response_carries_aggregated_content( + self, direct_key_client, converter_module, upstream_ok + ): + upstream_ok([sse_chunk("PROXY "), sse_chunk("OK"), sse_finish()]) + + response = direct_key_client.post("/v1/chat/completions", json={ + "model": "glm-5.2", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 100, + }) + assert response.json()["choices"][0]["message"]["content"] == "PROXY OK" + + +class TestChatCompletionsErrors: + def test_missing_messages_rejected(self, direct_key_client): + response = direct_key_client.post("/v1/chat/completions", json={"model": "glm-5.2"}) + assert response.status_code == 400 + + def test_invalid_json_rejected(self, direct_key_client): + response = direct_key_client.post( + "/v1/chat/completions", + content=b"not json", + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 400 + + def test_upstream_http_error_maps_to_502( + self, direct_key_client, converter_module, monkeypatch + ): + class FailingClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + def stream(self, *args, **kwargs): + raise httpx.ConnectError("connection refused") + + monkeypatch.setattr(converter_module.httpx, "AsyncClient", FailingClient) + + response = direct_key_client.post("/v1/chat/completions", json={ + "model": "glm-5.2", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 100, + }) + assert response.status_code == 502 + + +class TestTokenPathUnchanged: + def test_token_mode_requires_credential_manager(self, converter_module, client): + # Without credentials the token path must fail with 503, not fall back + # to the direct-key backend. + converter_module.CONFIG["api_key"] = "" + response = client.post("/v1/chat/completions", json={ + "model": "glm-5.2", + "messages": [{"role": "user", "content": "hi"}], + }) + assert response.status_code == 503 + + def test_token_mode_health_reports_credentials_missing(self, converter_module, client): + response = client.get("/health") + assert response.status_code == 200 + body = response.json() + assert body.get("cred") is None or "credential" not in body diff --git a/tests/test_converter_unit.py b/tests/test_converter_unit.py new file mode 100644 index 0000000..c8dd2e8 --- /dev/null +++ b/tests/test_converter_unit.py @@ -0,0 +1,178 @@ +"""Unit tests for pure helpers added or touched by the API key mode.""" + +import json +import time + +import pytest + + +class TestBackendForDomain: + def test_workbuddy_domain_maps_to_international_backend(self, converter_module): + assert ( + converter_module.backend_for_domain("www.workbuddy.ai") + == "https://www.workbuddy.ai" + ) + + def test_china_domain_maps_to_copilot_backend(self, converter_module): + assert ( + converter_module.backend_for_domain("www.codebuddy.cn") + == "https://copilot.tencent.com" + ) + + def test_none_domain_falls_back_to_china_backend(self, converter_module): + assert ( + converter_module.backend_for_domain(None) + == "https://copilot.tencent.com" + ) + + def test_unknown_domain_falls_back_to_china_backend(self, converter_module): + assert ( + converter_module.backend_for_domain("example.invalid") + == "https://copilot.tencent.com" + ) + + +class TestModelCatalogs: + def test_china_catalog_matches_upstream_defaults(self, converter_module): + # The desktop-token path must serve the same list as upstream. + expected = [ + "glm-5.2", "glm-5.1", "glm-5v-turbo", + "kimi-k2.7", "kimi-k2.6", "kimi-k2.5", + "deepseek-v4-pro", "deepseek-v4-flash", + "minimax-m3-pay", "hy3-preview-agent", "auto", + ] + assert converter_module.DEFAULT_MODELS == expected + assert converter_module.CN_MODELS == expected + + def test_international_catalog_is_separate_list(self, converter_module): + assert converter_module.INTL_MODELS is not converter_module.CN_MODELS + assert converter_module.INTL_MODELS != converter_module.CN_MODELS + + def test_international_catalog_contains_gpt56_family(self, converter_module): + for model in ("gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"): + assert model in converter_module.INTL_MODELS + + def test_international_catalog_contains_hy3_lowercase(self, converter_module): + # Upstream rejects "Hy3"; the catalog must advertise the working id. + assert "hy3" in converter_module.INTL_MODELS + assert "Hy3" not in converter_module.INTL_MODELS + + def test_auto_present_in_both_catalogs(self, converter_module): + assert "auto" in converter_module.CN_MODELS + assert "auto" in converter_module.INTL_MODELS + + def test_no_duplicate_ids_within_a_catalog(self, converter_module): + assert len(converter_module.CN_MODELS) == len(set(converter_module.CN_MODELS)) + assert len(converter_module.INTL_MODELS) == len(set(converter_module.INTL_MODELS)) + + +class TestFindAuthFile: + def test_finds_info_file_in_windows_layout(self, converter_module, tmp_path, monkeypatch): + auth_dir = tmp_path / "CodeBuddyExtension" / "Data" / "Public" / "auth" + auth_dir.mkdir(parents=True) + (auth_dir / "workbuddy-desktop-ai.info").write_text("{}", encoding="utf-8") + monkeypatch.setattr(converter_module, "auth_dirs", lambda: [auth_dir]) + found = converter_module.find_auth_file() + assert found is not None and found.name == "workbuddy-desktop-ai.info" + + def test_returns_none_when_directory_missing(self, converter_module, tmp_path, monkeypatch): + monkeypatch.setattr(converter_module, "auth_dirs", lambda: [tmp_path / "missing"]) + assert converter_module.find_auth_file() is None + + +class TestCredentialManager: + def _manager(self, converter_module, path): + return converter_module.CredentialManager(path) + + def test_loads_session_from_auth_file(self, converter_module, fake_auth_file): + path, payload = fake_auth_file + manager = self._manager(converter_module, path) + assert manager._session() == payload + + def test_not_expired_for_far_future_token(self, converter_module, fake_auth_file): + path, _ = fake_auth_file + manager = self._manager(converter_module, path) + assert manager._is_expired() is False + + def test_expired_for_past_token(self, converter_module, tmp_path): + path = tmp_path / "expired.info" + payload = { + "account": {"uid": "u", "nickname": "n"}, + "auth": {"accessToken": "t", "expiresAt": int(time.time() * 1000) - 10_000}, + } + path.write_text(json.dumps(payload), encoding="utf-8") + manager = self._manager(converter_module, path) + assert manager._is_expired() is True + + def test_missing_file_raises_on_session_access(self, converter_module, tmp_path): + manager = self._manager(converter_module, tmp_path / "absent.info") + with pytest.raises(RuntimeError, match="auth"): + manager._session() + + def test_headers_carry_bearer_and_user_id(self, converter_module, fake_auth_file): + path, payload = fake_auth_file + manager = self._manager(converter_module, path) + headers = manager.get_headers() + assert headers["Authorization"] == "Bearer test-access-token" + assert headers["X-User-Id"] == "test-uid-1234" + assert headers["X-Domain"] == "www.workbuddy.ai" + + def test_summary_exposes_nickname(self, converter_module, fake_auth_file): + path, _ = fake_auth_file + manager = self._manager(converter_module, path) + summary = manager.summary() + assert summary.get("nickname") == "tester@example.com" + + +class TestCheckAuth: + def test_open_access_when_no_server_key_configured(self, converter_module, fresh_config): + # Without --api-key the converter does not require client credentials. + converter_module._check_auth(None, None) + + def test_rejects_wrong_key(self, converter_module, fresh_config): + from fastapi import HTTPException + + fresh_config["api_key"] = "expected" + with pytest.raises(HTTPException) as exc: + converter_module._check_auth("Bearer wrong", None) + assert exc.value.status_code == 401 + + def test_rejects_missing_header_when_server_key_set(self, converter_module, fresh_config): + from fastapi import HTTPException + + fresh_config["api_key"] = "expected" + with pytest.raises(HTTPException) as exc: + converter_module._check_auth(None, None) + assert exc.value.status_code == 401 + + def test_accepts_matching_bearer_key(self, converter_module, fresh_config): + fresh_config["api_key"] = "expected" + converter_module._check_auth("Bearer expected", None) + + def test_accepts_matching_x_api_key_header(self, converter_module, fresh_config): + fresh_config["api_key"] = "expected" + converter_module._check_auth(None, "expected") + + def test_bearer_prefix_required_for_authorization_header(self, converter_module, fresh_config): + from fastapi import HTTPException + + fresh_config["api_key"] = "expected" + with pytest.raises(HTTPException): + converter_module._check_auth("expected", None) + + +class TestCredGuard: + def test_cred_raises_in_direct_key_mode(self, converter_module, fresh_config): + from fastapi import HTTPException + + fresh_config["direct_key"] = "ck_something" + with pytest.raises(HTTPException) as exc: + converter_module._cred() + assert exc.value.status_code == 500 + + def test_cred_raises_without_credentials_outside_direct_mode(self, converter_module, fresh_config): + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc: + converter_module._cred() + assert exc.value.status_code == 503