Compare commits

...

46 commits

Author SHA1 Message Date
cf7ed2f66a
Merge pull request #46 from jepler/behavioral-fixes-new-textual 2025-07-24 13:21:10 -05:00
077113632b core: List available presets in --help 2025-07-24 10:31:43 -05:00
3710a5b93b Use new textual markdown streaming 2025-07-24 10:30:16 -05:00
eecfa79d29
Merge pull request #44 from jepler/behavioral-fixes-new-textual 2025-05-16 11:36:21 +02:00
5d60927395 Fix app bindings more
ctrl-c can now be copy instead of yank (yay!)
2025-04-23 20:19:37 +02:00
633c43502a fix weird sizing of SubmittableTextArea
.. by removing the top & bottom borders. For some reason these appeared
not to be accounted when sizing the widget to its content?  not sure.
anyway this improves it.
2025-04-22 20:36:36 +02:00
70cbd2f806 Fix F9 binding of textarea 2025-04-22 20:25:37 +02:00
a8ed8d08d3
Merge pull request #43 from jepler/remove-codeql
codeql is not adding value
2025-04-19 20:34:10 +02:00
9aa3a1e910 codeql is not adding value 2025-04-19 20:29:11 +02:00
69566fddbe
Merge pull request #41 from jepler/improve-splat-handling 2024-10-25 08:22:48 -05:00
9340d3551c Make an API key name of "-" not actually fetch a key 2024-10-23 14:05:18 -05:00
b939bba50f Improve docs & chap --help
Incompatible change: @:FILE presets are now in $HOME/.config/chap/preset

Incompatible change: -S :FILE prompts are now in $HOME/.config/chap/prompt

Incompatible change: -s@FILE is no longer a special case,
use -S FILE instead
2024-10-23 14:05:18 -05:00
993b17845c
Merge pull request #40 from jepler/improve-key-handling 2024-10-23 11:47:20 -05:00
4fc36078a3 Add ability to get keys from a safe password store
I use password-store but this can be configured to use anything
that can print a key or secret on stdout.
2024-10-23 11:44:36 -05:00
d38a98ad90 Add UsesKeyMixin & make all key names settable as parameters 2024-10-23 11:43:46 -05:00
8f126d3516
Merge pull request #39 from jepler/misc-updates 2024-10-22 07:57:56 -05:00
06482245b7 Update default model to gpt-4o-mini
and give gpt-4o as the common alternative.
2024-10-22 07:56:40 -05:00
64c56e463b Recommend openai-chatgpt backend over llama-cpp backend 2024-10-22 07:56:40 -05:00
476dfc4b31 Allow setting temperature & top_p in chatgpt backend 2024-10-22 07:56:40 -05:00
e17b06e8d8 Remove a debug print 2024-10-22 07:56:40 -05:00
18616ac187
Merge pull request #38 from jepler/misc-updates
Update anthropic back-end & other small changes
2024-06-23 14:48:27 -05:00
0a98049f21 Update get_api to not have any required arguments 2024-06-23 14:46:18 -05:00
415e48496a update llama backend for llama-3-instruct style 2024-06-23 14:46:18 -05:00
aa1cb90c47 Update anthropic back-end 2024-06-23 14:46:18 -05:00
271792ed8c
Merge pull request #37 from jepler/actions-churn
update actions
2024-05-12 21:19:53 -05:00
adec4385ad one more action bump 2024-05-12 21:17:02 -05:00
5439ded9e8 update actions 2024-05-12 21:12:40 -05:00
ba1adecfbb
Update README.md 2024-05-08 19:46:28 -05:00
2475dc2fcb
Merge pull request #36 from jepler/misc-improvements
Various improvements
2024-05-08 19:42:09 -05:00
77297426d6 ruff & mypy fixes 2024-05-08 11:31:31 -05:00
7ea2649640 Commandline improvements
* @FILE arguments
 * -s:filename searches relative to the configuration path

this makes it easier to create whole preset configurations
2024-05-08 10:10:53 -05:00
90faa3d7a2 click deprecated MultiCommand; the replacement is Group. 2024-05-08 09:01:55 -05:00
f725e01114 openai: Allow specification of API URL
This is useful as some providers (including llama.cpp)
have an "openai-compatible API"
2024-05-01 20:40:54 -05:00
79997215ad
Merge pull request #35 from jepler/misc-improvements
switch openai default to gpt-4-turbo
2024-04-10 09:56:54 -05:00
08f3fa82d6 fix typing errors 2024-04-10 09:17:27 -05:00
afe22d0fdd chap ask: add --stdin flag 2024-04-10 08:33:55 -05:00
25dee73c0f Distinguish an empty system message from an unspecified one
.. when deciding whether to use the API's default system message
2024-04-10 08:33:27 -05:00
95a0a29055 Add -@ for reading system prompt from a file
`-S @...` did not have good ergonomics for completion.
2024-04-10 08:32:49 -05:00
61af71d0bd switch openai default to gpt-4-turbo 2024-04-10 08:31:28 -05:00
368275355b
Merge pull request #34 from jepler/mistral
Add mistral.ai backend
2024-03-09 20:57:48 -06:00
ddc5214231 Add mistral backend 2024-03-09 20:53:38 -06:00
9eef316a5c Change up llama.cpp prompting
this gives good results on mixtral.
2024-03-09 20:51:16 -06:00
fac2dfbdc3
Merge pull request #33 from jepler/anthropic-claude
Add an anthropic back-end
2024-03-08 14:26:56 -06:00
d63d8e6fe2 Add an anthropic back-end 2024-03-08 14:19:31 -06:00
2995b1e1aa
Merge pull request #31 from jepler/reduce-code-block-jank
Show code blocks at full height
2023-12-12 21:50:08 -06:00
9f6ace394a
Merge pull request #30 from jepler/multiline2
Switch chap tui to a multiline text field
2023-12-12 17:05:05 -06:00
16 changed files with 533 additions and 243 deletions

View file

@ -1,48 +0,0 @@
# SPDX-FileCopyrightText: 2022 Jeff Epler
#
# SPDX-License-Identifier: CC0-1.0
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: "53 3 * * 5"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ python ]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Dependencies (python)
run: pip3 install -r requirements-dev.txt
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{ matrix.language }}"

View file

@ -18,10 +18,10 @@ jobs:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.11

View file

@ -17,10 +17,10 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: pre-commit
uses: pre-commit/action@v3.0.0
uses: pre-commit/action@v3.0.1
- name: Make patch
if: failure()
@ -28,7 +28,7 @@ jobs:
- name: Upload patch
if: failure()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: patch
path: ~/pre-commit.patch
@ -36,10 +36,10 @@ jobs:
test-release:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.11
@ -53,7 +53,7 @@ jobs:
run: python -mbuild
- name: Upload artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/*

View file

@ -1,121 +0,0 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

View file

@ -81,6 +81,31 @@ Put your OpenAI API key in the platform configuration directory for chap, e.g.,
* `chap grep needle`
## `@FILE` arguments
It's useful to set a bunch of related arguments together, for instance to fully
configure a back-end. This functionality is implemented via `@FILE` arguments.
Before any other command-line argument parsing is performed, `@FILE` arguments are expanded:
* An `@FILE` argument is searched relative to the current directory
* An `@:FILE` argument is searched relative to the configuration directory (e.g., $HOME/.config/chap/presets)
* If an argument starts with a literal `@`, double it: `@@`
* `@.` stops processing any further `@FILE` arguments and leaves them unchanged.
The contents of an `@FILE` are parsed according to `shlex.split(comments=True)`.
Comments are supported.
A typical content might look like this:
```
# cfg/gpt-4o: Use more expensive gpt 4o and custom prompt
--backend openai-chatgpt
-B model:gpt-4o
-s :my-custom-system-message.txt
```
and you might use it with
```
chap @:cfg/gpt-4o ask what version of gpt is this
```
## Interactive terminal usage
The interactive terminal mode is accessed via `chap tui`.
@ -119,21 +144,26 @@ an existing session with `-s`. Or, you can continue the last session with
You can set the "system message" with the `-S` flag.
You can select the text generating backend with the `-b` flag:
* openai-chatgpt: the default, paid API, best quality results
* openai-chatgpt: the default, paid API, best quality results. Also works with compatible API implementations including llama-cpp when the correct backend URL is specified.
* llama-cpp: Works with [llama.cpp's http server](https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md) and can run locally with various models,
though it is [optimized for models that use the llama2-style prompting](https://huggingface.co/blog/llama2#how-to-prompt-llama-2).
Set the server URL with `-B url:...`.
though it is [optimized for models that use the llama2-style prompting](https://huggingface.co/blog/llama2#how-to-prompt-llama-2). Consider using llama.cpp's OpenAI compatible API with the openai-chatgpt backend instead, in which case the server can apply the chat template.
* textgen: Works with https://github.com/oobabooga/text-generation-webui and can run locally with various models.
Needs the server URL in *$configuration_directory/textgen\_url*.
* mistral: Works with the [mistral paid API](https://docs.mistral.ai/).
* anthropic: Works with the [anthropic paid API](https://docs.anthropic.com/en/home).
* huggingface: Works with the [huggingface API](https://huggingface.co/docs/api-inference/index), which includes a free tier.
* lorem: local non-AI lorem generator for testing
Backends have settings such as URLs and where API keys are stored. use `chap --backend
<BACKEND> --help` to list settings for a particular backend.
## Environment variables
The backend can be set with the `CHAP_BACKEND` environment variable.
Backend settings can be set with `CHAP_<backend_name>_<parameter_name>`, with `backend_name` and `parameter_name` all in caps.
For instance, `CHAP_LLAMA_CPP_URL=http://server.local:8080/completion` changes the default server URL for the llama-cpp back-end.
For instance, `CHAP_LLAMA_CPP_URL=http://server.local:8080/completion` changes the default server URL for the llama-cpp backend.
## Importing from ChatGPT

View file

@ -8,6 +8,6 @@ lorem-text
platformdirs
pyperclip
simple_parsing
textual[syntax]
textual[syntax] >= 4
tiktoken
websockets

View file

@ -0,0 +1,95 @@
# SPDX-FileCopyrightText: 2024 Jeff Epler <jepler@gmail.com>
#
# SPDX-License-Identifier: MIT
import json
from dataclasses import dataclass
from typing import AsyncGenerator, Any
import httpx
from ..core import AutoAskMixin, Backend
from ..key import UsesKeyMixin
from ..session import Assistant, Role, Session, User
class Anthropic(AutoAskMixin, UsesKeyMixin):
@dataclass
class Parameters:
url: str = "https://api.anthropic.com"
model: str = "claude-3-5-sonnet-20240620"
max_new_tokens: int = 1000
api_key_name = "anthropic_api_key"
def __init__(self) -> None:
super().__init__()
self.parameters = self.Parameters()
system_message = """\
Answer each question accurately and thoroughly.
"""
def make_full_query(self, messages: Session, max_query_size: int) -> dict[str, Any]:
system = [m.content for m in messages if m.role == Role.SYSTEM]
messages = [m for m in messages if m.role != Role.SYSTEM and m.content]
del messages[:-max_query_size]
result = dict(
model=self.parameters.model,
max_tokens=self.parameters.max_new_tokens,
messages=[dict(role=str(m.role), content=m.content) for m in messages],
stream=True,
)
if system and system[0]:
result["system"] = system[0]
return result
async def aask(
self,
session: Session,
query: str,
*,
max_query_size: int = 5,
timeout: float = 180,
) -> AsyncGenerator[str, None]:
new_content: list[str] = []
params = self.make_full_query(session + [User(query)], max_query_size)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST",
f"{self.parameters.url}/v1/messages",
json=params,
headers={
"x-api-key": self.get_key(),
"content-type": "application/json",
"anthropic-version": "2023-06-01",
"anthropic-beta": "messages-2023-12-15",
},
) as response:
if response.status_code == 200:
async for line in response.aiter_lines():
if line.startswith("data:"):
data = line.removeprefix("data:").strip()
j = json.loads(data)
content = j.get("delta", {}).get("text", "")
if content:
new_content.append(content)
yield content
else:
content = f"\nFailed with {response=!r}"
new_content.append(content)
yield content
async for line in response.aiter_lines():
new_content.append(line)
yield line
except httpx.HTTPError as e:
content = f"\nException: {e!r}"
new_content.append(content)
yield content
session.extend([User(query), Assistant("".join(new_content))])
def factory() -> Backend:
"""Uses the anthropic text-generation-interface web API"""
return Anthropic()

View file

@ -9,11 +9,11 @@ from typing import Any, AsyncGenerator
import httpx
from ..core import AutoAskMixin, Backend
from ..key import get_key
from ..key import UsesKeyMixin
from ..session import Assistant, Role, Session, User
class HuggingFace(AutoAskMixin):
class HuggingFace(AutoAskMixin, UsesKeyMixin):
@dataclass
class Parameters:
url: str = "https://api-inference.huggingface.co"
@ -24,6 +24,7 @@ class HuggingFace(AutoAskMixin):
after_user: str = """ [/INST] """
after_assistant: str = """ </s><s>[INST] """
stop_token_id = 2
api_key_name = "huggingface_api_token"
def __init__(self) -> None:
super().__init__()
@ -110,10 +111,6 @@ A dialog, where USER interacts with AI. AI is helpful, kind, obedient, honest, a
session.extend([User(query), Assistant("".join(new_content))])
@classmethod
def get_key(cls) -> str:
return get_key("huggingface_api_token")
def factory() -> Backend:
"""Uses the huggingface text-generation-interface web API"""

View file

@ -18,10 +18,16 @@ class LlamaCpp(AutoAskMixin):
url: str = "http://localhost:8080/completion"
"""The URL of a llama.cpp server's completion endpoint."""
start_prompt: str = """<s>[INST] <<SYS>>\n"""
after_system: str = "\n<</SYS>>\n\n"
after_user: str = """ [/INST] """
after_assistant: str = """ </s><s>[INST] """
start_prompt: str = "<|begin_of_text|>"
system_format: str = (
"<|start_header_id|>system<|end_header_id|>\n\n{}<|eot_id|>"
)
user_format: str = "<|start_header_id|>user<|end_header_id|>\n\n{}<|eot_id|>"
assistant_format: str = (
"<|start_header_id|>assistant<|end_header_id|>\n\n{}<|eot_id|>"
)
end_prompt: str = "<|start_header_id|>assistant<|end_header_id|>\n\n"
stop: str | None = None
def __init__(self) -> None:
super().__init__()
@ -34,17 +40,16 @@ A dialog, where USER interacts with AI. AI is helpful, kind, obedient, honest, a
def make_full_query(self, messages: Session, max_query_size: int) -> str:
del messages[1:-max_query_size]
result = [self.parameters.start_prompt]
formats = {
Role.SYSTEM: self.parameters.system_format,
Role.USER: self.parameters.user_format,
Role.ASSISTANT: self.parameters.assistant_format,
}
for m in messages:
content = (m.content or "").strip()
if not content:
continue
result.append(content)
if m.role == Role.SYSTEM:
result.append(self.parameters.after_system)
elif m.role == Role.ASSISTANT:
result.append(self.parameters.after_assistant)
elif m.role == Role.USER:
result.append(self.parameters.after_user)
result.append(formats[m.role].format(content))
full_query = "".join(result)
return full_query
@ -59,7 +64,7 @@ A dialog, where USER interacts with AI. AI is helpful, kind, obedient, honest, a
params = {
"prompt": self.make_full_query(session + [User(query)], max_query_size),
"stream": True,
"stop": ["</s>", "<s>", "[INST]"],
"stop": ["</s>", "<s>", "[INST]", "<|eot_id|>"],
}
new_content: list[str] = []
try:
@ -96,5 +101,10 @@ A dialog, where USER interacts with AI. AI is helpful, kind, obedient, honest, a
def factory() -> Backend:
"""Uses the llama.cpp completion web API"""
"""Uses the llama.cpp completion web API
Note: Consider using the openai-chatgpt backend with a custom URL instead.
The llama.cpp server will automatically apply common chat templates with the
openai-chatgpt backend, while chat templates must be manually configured client side
with this backend."""
return LlamaCpp()

View file

@ -0,0 +1,96 @@
# SPDX-FileCopyrightText: 2024 Jeff Epler <jepler@gmail.com>
#
# SPDX-License-Identifier: MIT
import json
from dataclasses import dataclass
from typing import AsyncGenerator, Any
import httpx
from ..core import AutoAskMixin
from ..key import UsesKeyMixin
from ..session import Assistant, Session, User
class Mistral(AutoAskMixin, UsesKeyMixin):
@dataclass
class Parameters:
url: str = "https://api.mistral.ai"
model: str = "open-mistral-7b"
max_new_tokens: int = 1000
api_key_name = "mistral_api_key"
def __init__(self) -> None:
super().__init__()
self.parameters = self.Parameters()
system_message = """\
Answer each question accurately and thoroughly.
"""
def make_full_query(self, messages: Session, max_query_size: int) -> dict[str, Any]:
messages = [m for m in messages if m.content]
del messages[1:-max_query_size]
result = dict(
model=self.parameters.model,
max_tokens=self.parameters.max_new_tokens,
messages=[dict(role=str(m.role), content=m.content) for m in messages],
stream=True,
)
return result
async def aask(
self,
session: Session,
query: str,
*,
max_query_size: int = 5,
timeout: float = 180,
) -> AsyncGenerator[str, None]:
new_content: list[str] = []
params = self.make_full_query(session + [User(query)], max_query_size)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST",
f"{self.parameters.url}/v1/chat/completions",
json=params,
headers={
"Authorization": f"Bearer {self.get_key()}",
"content-type": "application/json",
"accept": "application/json",
"model": "application/json",
},
) as response:
if response.status_code == 200:
async for line in response.aiter_lines():
if line.startswith("data:"):
data = line.removeprefix("data:").strip()
if data == "[DONE]":
break
j = json.loads(data)
content = (
j.get("choices", [{}])[0]
.get("delta", {})
.get("content", "")
)
if content:
new_content.append(content)
yield content
else:
content = f"\nFailed with {response=!r}"
new_content.append(content)
yield content
async for line in response.aiter_lines():
new_content.append(line)
yield line
except httpx.HTTPError as e:
content = f"\nException: {e!r}"
new_content.append(content)
yield content
session.extend([User(query), Assistant("".join(new_content))])
factory = Mistral

View file

@ -12,7 +12,7 @@ import httpx
import tiktoken
from ..core import Backend
from ..key import get_key
from ..key import UsesKeyMixin
from ..session import Assistant, Message, Session, User, session_to_list
@ -63,15 +63,29 @@ class EncodingMeta:
return cls(encoding, tokens_per_message, tokens_per_name, tokens_overhead)
class ChatGPT:
class ChatGPT(UsesKeyMixin):
@dataclass
class Parameters:
model: str = "gpt-3.5-turbo"
"""The model to use. The most common alternative value is 'gpt-4'."""
model: str = "gpt-4o-mini"
"""The model to use. The most common alternative value is 'gpt-4o'."""
max_request_tokens: int = 1024
"""The approximate greatest number of tokens to send in a request. When the session is long, the system prompt and 1 or more of the most recent interaction steps are sent."""
url: str = "https://api.openai.com/v1/chat/completions"
"""The URL of a chatgpt-compatible server's completion endpoint. Notably, llama.cpp's server is compatible with this backend, and can automatically apply common chat templates too."""
temperature: float | None = None
"""The model temperature for sampling"""
top_p: float | None = None
"""The model temperature for sampling"""
api_key_name: str = "openai_api_key"
"""The OpenAI API key"""
parameters: Parameters
def __init__(self) -> None:
self.parameters = self.Parameters()
@ -97,7 +111,7 @@ class ChatGPT:
def ask(self, session: Session, query: str, *, timeout: float = 60) -> str:
full_prompt = self.make_full_prompt(session + [User(query)])
response = httpx.post(
"https://api.openai.com/v1/chat/completions",
self.parameters.url,
json={
"model": self.parameters.model,
"messages": session_to_list(full_prompt),
@ -128,10 +142,12 @@ class ChatGPT:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST",
"https://api.openai.com/v1/chat/completions",
self.parameters.url,
headers={"authorization": f"Bearer {self.get_key()}"},
json={
"model": self.parameters.model,
"temperature": self.parameters.temperature,
"top_p": self.parameters.top_p,
"stream": True,
"messages": session_to_list(full_prompt),
},
@ -160,10 +176,6 @@ class ChatGPT:
session.extend([User(query), Assistant("".join(new_content))])
@classmethod
def get_key(cls) -> str:
return get_key("openai_api_key")
def factory() -> Backend:
"""Uses the OpenAI chat completion API"""

View file

@ -100,8 +100,9 @@ def verbose_ask(api: Backend, session: Session, q: str, print_prompt: bool) -> s
@command_uses_new_session
@click.option("--print-prompt/--no-print-prompt", default=True)
@click.argument("prompt", nargs=-1, required=True)
def main(obj: Obj, prompt: str, print_prompt: bool) -> None:
@click.option("--stdin/--no-stdin", "use_stdin", default=False)
@click.argument("prompt", nargs=-1)
def main(obj: Obj, prompt: list[str], use_stdin: bool, print_prompt: bool) -> None:
"""Ask a question (command-line argument is passed as prompt)"""
session = obj.session
assert session is not None
@ -112,9 +113,16 @@ def main(obj: Obj, prompt: str, print_prompt: bool) -> None:
api = obj.api
assert api is not None
if use_stdin:
if prompt:
raise click.UsageError("Can't use 'prompt' together with --stdin")
joined_prompt = sys.stdin.read()
else:
joined_prompt = " ".join(prompt)
# symlink_session_filename(session_filename)
response = verbose_ask(api, session, " ".join(prompt), print_prompt=print_prompt)
response = verbose_ask(api, session, joined_prompt, print_prompt=print_prompt)
print(f"Saving session to {session_filename}", file=sys.stderr)
if response is not None:

View file

@ -58,4 +58,4 @@ Markdown {
margin: 0 1 0 0;
}
SubmittableTextArea { height: 3 }
SubmittableTextArea { height: auto; min-height: 5; margin: 0; border: none; border-left: heavy $primary }

View file

@ -38,7 +38,7 @@ ANSI_SEQUENCES_KEYS["\x1b\n"] = (Keys.F9,) # type: ignore
class SubmittableTextArea(TextArea):
BINDINGS = [
Binding("f9", "submit", "Submit", show=True),
Binding("f9", "app.submit", "Submit", show=True),
Binding("tab", "focus_next", show=False, priority=True), # no inserting tabs
]
@ -51,10 +51,10 @@ def parser_factory() -> MarkdownIt:
class ChapMarkdown(Markdown, can_focus=True, can_focus_children=False):
BINDINGS = [
Binding("ctrl+y", "yank", "Yank text", show=True),
Binding("ctrl+r", "resubmit", "resubmit", show=True),
Binding("ctrl+x", "redraft", "redraft", show=True),
Binding("ctrl+q", "toggle_history", "history toggle", show=True),
Binding("ctrl+c", "app.yank", "Copy text", show=True),
Binding("ctrl+r", "app.resubmit", "resubmit", show=True),
Binding("ctrl+x", "app.redraft", "redraft", show=True),
Binding("ctrl+q", "app.toggle_history", "history toggle", show=True),
]
@ -75,7 +75,7 @@ class CancelButton(Button):
class Tui(App[None]):
CSS_PATH = "tui.css"
BINDINGS = [
Binding("ctrl+c", "quit", "Quit", show=True, priority=True),
Binding("ctrl+q", "quit", "Quit", show=True, priority=True),
]
def __init__(
@ -145,7 +145,6 @@ class Tui(App[None]):
await self.container.mount_all(
[markdown_for_step(User(query)), output], before="#pad"
)
tokens: list[str] = []
update: asyncio.Queue[bool] = asyncio.Queue(1)
for markdown in self.container.children:
@ -166,15 +165,22 @@ class Tui(App[None]):
)
async def render_fun() -> None:
old_len = 0
while await update.get():
if tokens:
output.update("".join(tokens).strip())
self.container.scroll_end()
await asyncio.sleep(0.1)
content = message.content
new_len = len(content)
new_content = content[old_len:new_len]
if new_content:
if old_len:
await output.append(new_content)
else:
output.update(content)
self.container.scroll_end()
old_len = new_len
await asyncio.sleep(0.01)
async def get_token_fun() -> None:
async for token in self.api.aask(session, query):
tokens.append(token)
message.content += token
try:
update.put_nowait(True)
@ -292,7 +298,9 @@ def main(obj: Obj, replace_system_prompt: bool) -> None:
assert session_filename is not None
if replace_system_prompt:
session[0].content = obj.system_message or api.system_message
session[0].content = (
api.system_message if obj.system_message is None else obj.system_message
)
tui = Tui(api, session)
tui.run()

View file

@ -3,13 +3,17 @@
# SPDX-License-Identifier: MIT
from collections.abc import Sequence
import asyncio
import datetime
import io
import importlib
import os
import pathlib
import pkgutil
import subprocess
import shlex
import textwrap
from dataclasses import MISSING, Field, dataclass, fields
from typing import (
Any,
@ -17,6 +21,7 @@ from typing import (
Callable,
Optional,
Union,
IO,
cast,
get_origin,
get_args,
@ -27,7 +32,6 @@ import click
import platformdirs
from simple_parsing.docstring import get_attribute_docstring
from typing_extensions import Protocol
from . import backends, commands
from .session import Message, Session, System, session_from_file
@ -39,6 +43,8 @@ else:
conversations_path = platformdirs.user_state_path("chap") / "conversations"
conversations_path.mkdir(parents=True, exist_ok=True)
configuration_path = platformdirs.user_config_path("chap")
preset_path = configuration_path / "preset"
class ABackend(Protocol):
@ -125,7 +131,11 @@ def configure_api_from_environment(
setattr(api.parameters, field.name, tv)
def get_api(ctx: click.Context, name: str = "openai_chatgpt") -> Backend:
def get_api(ctx: click.Context | None = None, name: str | None = None) -> Backend:
if ctx is None:
ctx = click.Context(click.Command("chap"))
if name is None:
name = os.environ.get("CHAP_BACKEND", "openai_chatgpt")
name = name.replace("-", "_")
backend = cast(
Backend, importlib.import_module(f"{__package__}.backends.{name}").factory()
@ -177,12 +187,20 @@ def colonstr(arg: str) -> tuple[str, str]:
def set_system_message(ctx: click.Context, param: click.Parameter, value: str) -> None:
if value and value.startswith("@"):
with open(value[1:], "r", encoding="utf-8") as f:
value = f.read().rstrip()
if value is None:
return
ctx.obj.system_message = value
def set_system_message_from_file(
ctx: click.Context, param: click.Parameter, value: io.TextIOWrapper
) -> None:
if value is None:
return
content = value.read().strip()
ctx.obj.system_message = content
def set_backend(ctx: click.Context, param: click.Parameter, value: str) -> None:
if value == "list":
formatter = ctx.make_formatter()
@ -321,7 +339,43 @@ class Obj:
session_filename: Optional[pathlib.Path] = None
class MyCLI(click.MultiCommand):
def maybe_add_txt_extension(fn: pathlib.Path) -> pathlib.Path:
if not fn.exists():
fn1 = pathlib.Path(str(fn) + ".txt")
if fn1.exists():
fn = fn1
return fn
def expand_splats(args: list[str]) -> list[str]:
result = []
saw_at_dot = False
for a in args:
if a == "@.":
saw_at_dot = True
continue
if saw_at_dot:
result.append(a)
continue
if a.startswith("@@"): ## double @ to escape an argument that starts with @
result.append(a[1:])
continue
if not a.startswith("@"):
result.append(a)
continue
if a.startswith("@:"):
fn: pathlib.Path = preset_path / a[2:]
else:
fn = pathlib.Path(a[1:])
fn = maybe_add_txt_extension(fn)
with open(fn, "r", encoding="utf-8") as f:
content = f.read()
parts = shlex.split(content)
result.extend(expand_splats(parts))
return result
class MyCLI(click.Group):
def make_context(
self,
info_name: Optional[str],
@ -350,14 +404,117 @@ class MyCLI(click.MultiCommand):
except ModuleNotFoundError as exc:
raise click.UsageError(f"Invalid subcommand {cmd_name!r}", ctx) from exc
def gather_preset_info(self) -> list[tuple[str, str]]:
result = []
for p in preset_path.glob("*"):
if p.is_file():
with p.open() as f:
first_line = f.readline()
if first_line.startswith("#"):
help_str = first_line[1:].strip()
else:
help_str = "(A comment on the first line would be shown here)"
result.append((f"@:{p.name}", help_str))
return result
def format_splat_options(
self, ctx: click.Context, formatter: click.HelpFormatter
) -> None:
with formatter.section("Splats"):
formatter.write_text(
"Before any other command-line argument parsing is performed, @FILE arguments are expanded:"
)
formatter.write_paragraph()
formatter.indent()
formatter.write_dl(
[
("@FILE", "Argument is searched relative to the current directory"),
(
"@:FILE",
"Argument is searched relative to the configuration directory (e.g., $HOME/.config/chap/preset)",
),
("@@…", "If an argument starts with a literal '@', double it"),
(
"@.",
"Stops processing any further `@FILE` arguments and leaves them unchanged.",
),
]
)
formatter.dedent()
formatter.write_paragraph()
formatter.write_text(
textwrap.dedent(
"""\
The contents of an `@FILE` are parsed by `shlex.split(comments=True)`.
Comments are supported. If the filename ends in .txt,
the extension may be omitted."""
)
)
formatter.write_paragraph()
if preset_info := self.gather_preset_info():
formatter.write_text("Presets found:")
formatter.write_paragraph()
formatter.indent()
formatter.write_dl(preset_info)
formatter.dedent()
def format_options(
self, ctx: click.Context, formatter: click.HelpFormatter
) -> None:
self.format_splat_options(ctx, formatter)
super().format_options(ctx, formatter)
api = ctx.obj.api or get_api(ctx)
if hasattr(api, "parameters"):
format_backend_help(api, formatter)
def main(
self,
args: Sequence[str] | None = None,
prog_name: str | None = None,
complete_var: str | None = None,
standalone_mode: bool = True,
windows_expand_args: bool = True,
**extra: Any,
) -> Any:
if args is None:
args = sys.argv[1:]
if os.name == "nt" and windows_expand_args:
args = click.utils._expand_args(args)
else:
args = list(args)
args = expand_splats(args)
return super().main(
args,
prog_name=prog_name,
complete_var=complete_var,
standalone_mode=standalone_mode,
windows_expand_args=windows_expand_args,
**extra,
)
class ConfigRelativeFile(click.File):
def __init__(self, mode: str, where: str) -> None:
super().__init__(mode)
self.where = where
def convert(
self,
value: str | os.PathLike[str] | IO[Any],
param: click.Parameter | None,
ctx: click.Context | None,
) -> Any:
if isinstance(value, str):
if value.startswith(":"):
value = configuration_path / self.where / value[1:]
else:
value = pathlib.Path(value)
if isinstance(value, pathlib.Path):
value = maybe_add_txt_extension(value)
return super().convert(value, param, ctx)
main = MyCLI(
help="Commandline interface to ChatGPT",
@ -369,6 +526,14 @@ main = MyCLI(
help="Show the version and exit",
callback=version_callback,
),
click.Option(
("--system-message-file", "-s"),
type=ConfigRelativeFile("r", where="prompt"),
default=None,
callback=set_system_message_from_file,
expose_value=False,
help=f"Set the system message from a file. If the filename starts with `:` it is relative to the {configuration_path}/prompt. If the filename ends in .txt, the extension may be omitted.",
),
click.Option(
("--system-message", "-S"),
type=str,

View file

@ -2,25 +2,63 @@
#
# SPDX-License-Identifier: MIT
import json
import subprocess
from typing import Protocol
import functools
import platformdirs
class APIKeyProtocol(Protocol):
@property
def api_key_name(self) -> str:
...
class HasKeyProtocol(Protocol):
@property
def parameters(self) -> APIKeyProtocol:
...
class UsesKeyMixin:
def get_key(self: HasKeyProtocol) -> str:
return get_key(self.parameters.api_key_name)
class NoKeyAvailable(Exception):
pass
_key_path_base = platformdirs.user_config_path("chap")
USE_PASSWORD_STORE = _key_path_base / "USE_PASSWORD_STORE"
@functools.cache
def get_key(name: str, what: str = "openai api key") -> str:
key_path = _key_path_base / name
if not key_path.exists():
raise NoKeyAvailable(
f"Place your {what} in {key_path} and run the program again"
)
if USE_PASSWORD_STORE.exists():
content = USE_PASSWORD_STORE.read_text(encoding="utf-8")
if content.strip():
cfg = json.loads(content)
pass_command: list[str] = cfg.get("PASS_COMMAND", ["pass", "show"])
pass_prefix: str = cfg.get("PASS_PREFIX", "chap/")
with open(key_path, encoding="utf-8") as f:
return f.read().strip()
@functools.cache
def get_key(name: str, what: str = "api key") -> str:
if name == "-":
return "-"
key_path = f"{pass_prefix}{name}"
command = pass_command + [key_path]
return subprocess.check_output(command, encoding="utf-8").split("\n")[0]
else:
@functools.cache
def get_key(name: str, what: str = "api key") -> str:
key_path = _key_path_base / name
if not key_path.exists():
raise NoKeyAvailable(
f"Place your {what} in {key_path} and run the program again"
)
with open(key_path, encoding="utf-8") as f:
return f.read().strip()