Skip to content

fix(copilot): fixes from testing and integration#131

Open
theblazehen wants to merge 11 commits intoMirrowel:devfrom
theblazehen:copilot-fixes
Open

fix(copilot): fixes from testing and integration#131
theblazehen wants to merge 11 commits intoMirrowel:devfrom
theblazehen:copilot-fixes

Conversation

@theblazehen
Copy link

@theblazehen theblazehen commented Feb 15, 2026

Summary

Fixes found while testing and integrating the Copilot provider:

  • Credential setup flow: Added setup_credential() method with email-based deduplication and env export support
  • Email null fallback: GitHub API sometimes returns null email — fall back to login username
  • Provider-prefixed models: get_models() now returns copilot/gpt-5 instead of bare gpt-5 so they're directly usable in /v1/models
  • Expanded model catalog: Updated DEFAULT_COPILOT_MODELS from 8 → 20 models (GPT-5.x, Claude 4.5/4.6, Gemini 3, Grok)
  • credential_identifier fix: acompletion() now reads credential_identifier kwarg (used by the rotator) instead of only api_key
  • Await streaming handler: _handle_streaming_response was not awaited in the completion path
  • Stream delta compatibility: Use plain dicts instead of litellm.Choices/litellm.Delta objects for streaming chunks (avoids serialization issues)
  • ResponseNotRead fix: Explicitly aread() response body before logging HTTP errors in streaming mode

Test plan

  • Non-streaming chat completions via proxy
  • Streaming chat completions via proxy
  • Credential rotation between multiple Copilot accounts
  • Token refresh (Copilot API tokens expire ~30min)
  • 401 re-auth flow

🤖 Generated with Claude Code

mirrobot-agent[bot] and others added 9 commits December 5, 2025 10:21
Implements GitHub Copilot integration with:

- Device Flow OAuth authentication (no browser redirect needed)
- Custom Copilot Chat API integration (bypasses LiteLLM)
- Configurable X-Initiator header control (user vs agent mode)
- Support for both github.com and GitHub Enterprise
- Vision request support
- Streaming and non-streaming responses

Key features from the referenced implementations:
- Based on https://github.com/sst/opencode-copilot-auth for OAuth flow
- Agent header forcing feature from Tarquinen/dotfiles plugin
- COPILOT_AGENT_PERCENTAGE env var for random user/agent ratio

New files:
- copilot_auth_base.py: GitHub Device Flow OAuth handler
- copilot_provider.py: Custom API integration

Closes Mirrowel#29
@mirrobot-agent
Copy link
Contributor

I'm starting my review of the Copilot provider fixes. I'll be looking closely at the new credential setup flow and the streaming response handling. Back soon with the full report!

@theblazehen theblazehen changed the title fix(copilot): battle-tested fixes from production use fix(copilot): fixes from testing and integration Feb 15, 2026
Copy link
Contributor

@mirrobot-agent mirrobot-agent bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall Assessment

This PR provides a solid set of improvements for the Copilot provider, particularly around production stability and credential management. The fixes for streaming response handling (awaiting the handler and handling ResponseNotRead errors) are critical and well-implemented. The expansion of the model catalog and the improved model prefixing logic significantly enhance the provider's usability.

Architectural Feedback

  • Credential Storage: The introduction of setup_credential and associated management methods is a great step toward standardized credential handling. Using the email for deduplication is a robust approach.
  • Streaming Compatibility: The switch to plain dictionaries for streaming chunks is an interesting choice to avoid serialization issues. While practical, it's important to ensure this doesn't break expectations for downstream consumers that might rely on LiteLLM's object structure.

Key Suggestions

  • Locking Mechanism: The in-memory lock key using id(creds) might be slightly fragile if objects are cloned.
  • Persistence Path: Consider using a more global configuration directory for OAuth credentials to avoid "orphaned" credential files when running from different directories.

Verified Fixes

  • Streaming Await: Correctly added await to _handle_streaming_response call.
  • Error Handling: Implemented aread() for error responses in streaming mode, preventing ResponseNotRead exceptions.
  • Email Fallback: Added reliable fallback from email to login username for GitHub identity.

Questions for the Author

  1. Was the change from litellm.Choices to plain dictionaries tested with all intended downstream consumers?
  2. Is the oauth_creds directory intended to be local to the project root, or would a global config directory be more appropriate for the CLI use case?

This review was generated by an AI assistant.


def _get_oauth_base_dir(self) -> Path:
"""Return the OAuth credentials base directory."""
return Path.cwd() / "oauth_creds"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Path.cwd() / "oauth_creds" might cause credentials to be scattered if the application is executed from different directories. Consider using a standardized user configuration directory (e.g., Path.home() / ".config/llm-proxy/oauth_creds") to ensure persistence across sessions.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 229257e. Now uses the centralized get_oauth_dir() from utils/paths.py which handles PyInstaller mode and overrides.

The Copilot API token (access_token) expires after ~30 minutes.
"""
async with await self._get_lock(path):
lock_key = path or f"in-memory://copilot/{id(creds)}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using id(creds) for an in-memory lock key is clever, but be aware that it might lead to redundant locks if the creds dictionary is copied or recreated (e.g., during serialization/deserialization cycles). If the credentials have a unique identifier like an email, that might be a more stable key.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — id() can be recycled if the dict gets garbage collected and recreated. In practice the in-memory path only triggers for env-based credentials which are long-lived singletons, but using the email from _proxy_metadata would be more robust. Open to changing if preferred.

}
)

return litellm.ModelResponse(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While using plain dictionaries for choices avoids serialization issues, it might break compatibility with LiteLLM utilities or downstream consumers that expect litellm.Choices objects. Have you verified that this change doesn’t affect any of the proxy’s core functionality or existing integrations?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — the litellm.Choices/litellm.Delta objects were causing serialization issues during streaming. ModelResponse() accepts plain dicts for choices and handles the wrapping internally, so downstream consumers still get proper objects. Tested with both streaming and non-streaming through the full proxy pipeline.

4. Parses response into LiteLLM format
"""
credential_path = kwargs.get("api_key", "")
credential_path = kwargs.pop("credential_identifier", kwargs.get("api_key", ""))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good addition of credential_identifier. However, kwargs.get("api_key", "") is used as a fallback. In this provider, api_key is repurposed to store the credential path. It might be worth adding a comment to clarify this for future maintainers.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. For context: all OAuth-based providers in this project pass credential file paths (or env:// virtual paths) through the api_key/credential_identifier field since the rotator assigns credentials per-request. The credential_identifier kwarg was added in a later refactor as the canonical way to pass this, with api_key kept as fallback for backward compat.

…th.cwd()

Avoids credentials scattering if run from different directories and
respects PyInstaller/override modes via utils.paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Mirrowel Mirrowel added enhancement New feature or request Agent Monitored Monitored for AI Agent to review PR's and commits Priority labels Feb 15, 2026
@Mirrowel
Copy link
Owner

Mirrowel commented Mar 3, 2026

@greptile

@greptile-apps
Copy link

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR fixes several bugs discovered during integration testing of the Copilot provider: an un-awaited streaming handler, wrong kwarg name for credential rotation (credential_identifier vs api_key), streaming delta serialization failures, and a ResponseNotRead error in the HTTP error logger. It also introduces a full credential lifecycle API (setup_credential, list_credentials, delete_credential, export_credential_to_env) and expands the default model catalog.

Key observations:

  • Streaming fix (await _handle_streaming_response) and credential_identifier pop are both correct and important for the rotator to work.
  • Plain-dict stream deltas cleanly avoid litellm serialization issues without changing the consumer contract.
  • aread() before logging correctly resolves ResponseNotRead for streaming error paths.
  • export_credential_to_env writes a long-lived OAuth token to disk without setting 0o600 permissions (unlike _save_credentials), leaving the file readable by other OS users.
  • _get_next_credential_number has a TOCTOU race: two concurrent setup_credential() calls can compute the same next slot and overwrite each other's credential file.
  • build_env_lines produces output without a trailing newline, which violates POSIX text-file conventions and can confuse shell source / dotenv parsers.

Confidence Score: 3/5

  • Core streaming/auth fixes are safe to merge, but the new credential management helpers have a security gap (world-readable token export) and a race condition that should be addressed before broad use.
  • The provider-level bug fixes (await, credential_identifier, delta dicts, aread) are well-scoped and correct. The new copilot_auth_base.py additions introduce a file-permission security issue in export_credential_to_env and an unguarded TOCTOU window in _get_next_credential_number. The paths.py import issue (flagged in a prior thread) remains unresolved and will cause a ModuleNotFoundError at startup on the base branch.
  • src/rotator_library/providers/copilot_auth_base.py — specifically export_credential_to_env (missing chmod) and _get_next_credential_number (missing allocation lock).

Important Files Changed

Filename Overview
src/rotator_library/providers/copilot_auth_base.py Adds credential lifecycle management (setup, list, delete, export). The exported .env file lacks restrictive permissions (unlike _save_credentials which uses 0o600), and _get_next_credential_number has a TOCTOU race between number allocation and file write. Email null-to-login fallback fix is correct.
src/rotator_library/providers/copilot_provider.py Fixes await on streaming handler, credential_identifier kwarg extraction, stream delta serialization (plain dicts instead of litellm objects), ResponseNotRead guard, and provider-prefixed model names. Changes are correct and well-targeted.

Sequence Diagram

sequenceDiagram
    participant Client
    participant CopilotProvider
    participant CopilotAuthBase
    participant GitHubAPI
    participant CopilotAPI

    Note over Client,CopilotAPI: setup_credential() flow
    Client->>CopilotAuthBase: setup_credential()
    CopilotAuthBase->>CopilotAuthBase: initialize_token(temp_creds)
    CopilotAuthBase->>GitHubAPI: POST /login/device/code
    GitHubAPI-->>CopilotAuthBase: device_code, user_code
    Note over CopilotAuthBase: Display code to user
    loop Poll until authorized
        CopilotAuthBase->>GitHubAPI: POST /login/oauth/access_token
        GitHubAPI-->>CopilotAuthBase: github_token (on success)
    end
    CopilotAuthBase->>GitHubAPI: GET /user (fetch email/login)
    GitHubAPI-->>CopilotAuthBase: email or login
    CopilotAuthBase->>CopilotAPI: GET /copilot_internal/v2/token
    CopilotAPI-->>CopilotAuthBase: copilot_access_token
    CopilotAuthBase->>CopilotAuthBase: _find_existing_credential_by_email()
    CopilotAuthBase->>CopilotAuthBase: _save_credentials(file_path, creds)
    CopilotAuthBase-->>Client: CopilotCredentialSetupResult

    Note over Client,CopilotAPI: acompletion() streaming flow
    Client->>CopilotProvider: acompletion(credential_identifier=..., stream=true)
    CopilotProvider->>CopilotAuthBase: _load_credentials(credential_path)
    CopilotAuthBase-->>CopilotProvider: creds
    alt token expired
        CopilotProvider->>CopilotAuthBase: _refresh_copilot_token(path, creds)
        CopilotAuthBase->>CopilotAPI: GET /copilot_internal/v2/token
        CopilotAPI-->>CopilotAuthBase: new copilot_access_token
        CopilotAuthBase-->>CopilotProvider: refreshed creds
    end
    CopilotProvider->>CopilotProvider: await _handle_streaming_response()
    CopilotProvider->>CopilotAPI: POST /chat/completions (stream=true)
    loop SSE chunks
        CopilotAPI-->>CopilotProvider: data: {...}
        CopilotProvider-->>Client: litellm.ModelResponse (plain dict delta)
    end
Loading

Last reviewed commit: 6566ab0

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

import logging
import re
from pathlib import Path
from ..utils.paths import get_oauth_dir
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import will fail - paths.py doesn't exist in base branch

The file src/rotator_library/utils/paths.py does not exist in the base branch feature/copilot-provider. This import will fail with ModuleNotFoundError at runtime.

The paths.py module exists in main branch (added in commit 467f294), but the target branch doesn't have it yet. Either:

  1. Merge/rebase with main to get paths.py first
  2. Revert to using Path.cwd() / "oauth_creds" directly (like other files in this branch do)
Suggested change
from ..utils.paths import get_oauth_dir
# from ..utils.paths import get_oauth_dir # TODO: add after merging with main
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/rotator_library/providers/copilot_auth_base.py
Line: 21

Comment:
Import will fail - `paths.py` doesn't exist in base branch

The file `src/rotator_library/utils/paths.py` does not exist in the base branch `feature/copilot-provider`. This import will fail with `ModuleNotFoundError` at runtime.

The `paths.py` module exists in `main` branch (added in commit 467f294), but the target branch doesn't have it yet. Either:
1. Merge/rebase with `main` to get `paths.py` first
2. Revert to using `Path.cwd() / "oauth_creds"` directly (like other files in this branch do)

```suggestion
# from ..utils.paths import get_oauth_dir  # TODO: add after merging with main
```

How can I resolve this? If you propose a fix, please make it concise.

get_user_info() always returns at least {"email": "unknown"}, so
the previous 'if not email' check was unreachable dead code. Now also
checks for the 'unknown' sentinel to prevent creating credentials with
placeholder emails that could cause deduplication collisions.
@theblazehen theblazehen changed the base branch from feature/copilot-provider to dev March 7, 2026 08:53
@theblazehen theblazehen requested a review from Mirrowel as a code owner March 7, 2026 08:53
@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • dev

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: cdf91f7c-4015-41a0-8cda-59c0fa10426e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Comment @coderabbitai help to get the list of available commands and usage tips.

Comment on lines +800 to +801
with open(env_path, "w") as f:
f.write("\n".join(self.build_env_lines(creds, cred_number)))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exported .env file written without restrictive permissions

export_credential_to_env writes the GitHub OAuth token (a long-lived secret) to disk without restricting file permissions. _save_credentials (line 243) correctly sets 0o600 after an atomic write, but the .env exporter skips this step, leaving the file world-readable by default (subject to the process umask).

Suggested change
with open(env_path, "w") as f:
f.write("\n".join(self.build_env_lines(creds, cred_number)))
with open(env_path, "w") as f:
f.write("\n".join(self.build_env_lines(creds, cred_number)))
try:
os.chmod(env_path, 0o600)
except (OSError, AttributeError):
pass

Comment on lines +684 to +700
def _get_next_credential_number(self, base_dir: Optional[Path] = None) -> int:
"""Get next available credential number."""
if base_dir is None:
base_dir = self._get_oauth_base_dir()

prefix = self._get_provider_file_prefix()
pattern = str(base_dir / f"{prefix}_oauth_*.json")

existing_numbers = []
for cred_file in glob(pattern):
match = re.search(r"_oauth_(\d+)\.json$", cred_file)
if match:
existing_numbers.append(int(match.group(1)))

if not existing_numbers:
return 1
return max(existing_numbers) + 1
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TOCTOU race condition in credential number allocation

_get_next_credential_number reads the directory and returns max + 1, but there is no lock guarding the read→allocate→write sequence. Two concurrent setup_credential() calls (e.g., from a parallel CLI invocation or tests) can both observe the same max, both compute the same next number, and then both try to write to the same file path. The second write will silently overwrite the first credential.

Consider holding a file-system level lock (e.g., fcntl.flock on a .lock file inside base_dir) around the allocation + save sequence in setup_credential(), or use an atomic O_EXCL open to detect collision and retry with the next number.

Comment on lines +763 to +778
def build_env_lines(self, creds: Dict[str, Any], cred_number: int) -> list[str]:
"""Generate .env file lines for a Copilot credential."""
email = creds.get("_proxy_metadata", {}).get("email", "unknown")
prefix = f"COPILOT_{cred_number}"

lines = [
f"# COPILOT Credential #{cred_number} for: {email}",
f"# Exported from: copilot_oauth_{cred_number}.json",
f"# Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}",
"",
f"{prefix}_GITHUB_TOKEN={creds.get('refresh_token', '')}",
f"{prefix}_ENTERPRISE_URL={creds.get('enterprise_url', '')}",
f"{prefix}_EMAIL={email}",
]

return lines
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.env output is missing a trailing newline

build_env_lines returns a list whose elements are joined with "\n" in export_credential_to_env, but there is no final "\n" after the last line. POSIX defines a text file as a sequence of lines each terminated by a newline, and many tools (diff, wc -l, source, etc.) behave unexpectedly on files that lack a trailing newline.

Add an empty string at the end of lines so that "\n".join(lines) produces the required trailing newline:

lines = [
    ...,
    f"{prefix}_EMAIL={email}",
    "",   # ← produces trailing newline when joined
]

@theblazehen
Copy link
Author

Addressed in 6566ab0 + retargeted PR to dev:

Re: get_oauth_dir import failure (Greptile) — Retargeted the PR base from feature/copilot-provider to dev, which already has utils/paths.py. This was a branch ordering issue, not a code defect.

Re: unreachable if not email: check (Greptile) — Good catch. get_user_info() always returns at least "unknown", so the check was dead code. Fixed to if not email or email == "unknown": to actually catch the failure case and prevent creating credentials with placeholder emails that could collide during deduplication.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Monitored Monitored for AI Agent to review PR's and commits enhancement New feature or request Priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants